diff --git a/.devcontainer/cli/Dockerfile b/.devcontainer/cli/Dockerfile
index 0317097..ab13330 100644
--- a/.devcontainer/cli/Dockerfile
+++ b/.devcontainer/cli/Dockerfile
@@ -9,7 +9,8 @@ RUN apk add --no-cache composer
# Add Chromium and Image Magick for puppeteer.
RUN apk add --no-cache \
imagemagick-dev \
- chromium
+ chromium \
+ libzip-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1
@@ -19,7 +20,7 @@ RUN chmod 777 /usr/src/php/ext/imagick
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
# Install PHP extensions
-RUN docker-php-ext-install imagick
+RUN docker-php-ext-install imagick zip
# Composer uses its php binary, but we want it to use the container's one
RUN rm -f /usr/bin/php84
diff --git a/.devcontainer/fpm/Dockerfile b/.devcontainer/fpm/Dockerfile
index 8c585c8..3e658b6 100644
--- a/.devcontainer/fpm/Dockerfile
+++ b/.devcontainer/fpm/Dockerfile
@@ -14,7 +14,8 @@ RUN apk add --no-cache \
nodejs \
npm \
imagemagick-dev \
- chromium
+ chromium \
+ libzip-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1
@@ -24,7 +25,7 @@ RUN chmod 777 /usr/src/php/ext/imagick
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
# Install PHP extensions
-RUN docker-php-ext-install imagick
+RUN docker-php-ext-install imagick zip
RUN rm -f /usr/bin/php84
RUN ln -s /usr/local/bin/php /usr/bin/php84
diff --git a/.gitignore b/.gitignore
index 02f3d78..1f4f617 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,8 @@ yarn-error.log
/.junie/guidelines.md
/CLAUDE.md
/.mcp.json
+/.ai
+.DS_Store
+/boost.json
+/.gemini
+/GEMINI.md
diff --git a/README.md b/README.md
index 34f5c3d..acb0b5c 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
-It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (100+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), ~500 from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 35k downloads and 150+ stars, it’s the most popular community-driven BYOS.
+It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, it’s the most popular community-driven BYOS.


diff --git a/app/Jobs/FetchDeviceModelsJob.php b/app/Jobs/FetchDeviceModelsJob.php
index cb24d98..475c5c7 100644
--- a/app/Jobs/FetchDeviceModelsJob.php
+++ b/app/Jobs/FetchDeviceModelsJob.php
@@ -199,6 +199,7 @@ final class FetchDeviceModelsJob implements ShouldQueue
'offset_x' => $modelData['offset_x'] ?? 0,
'offset_y' => $modelData['offset_y'] ?? 0,
'published_at' => $modelData['published_at'] ?? null,
+ 'kind' => $modelData['kind'] ?? null,
'source' => 'api',
];
diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php
index 9132d6c..68f8e7e 100644
--- a/app/Models/Plugin.php
+++ b/app/Models/Plugin.php
@@ -24,6 +24,7 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
+use InvalidArgumentException;
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
use Keepsuit\Liquid\Exceptions\LiquidException;
use Keepsuit\Liquid\Extensions\StandardExtension;
@@ -44,6 +45,8 @@ class Plugin extends Model
'no_bleed' => 'boolean',
'dark_mode' => 'boolean',
'preferred_renderer' => 'string',
+ 'plugin_type' => 'string',
+ 'alias' => 'boolean',
];
protected static function boot()
@@ -62,6 +65,11 @@ class Plugin extends Model
$model->current_image = null;
}
});
+
+ // Sanitize configuration template on save
+ static::saving(function ($model): void {
+ $model->sanitizeTemplate();
+ });
}
public function user()
@@ -69,6 +77,25 @@ class Plugin extends Model
return $this->belongsTo(User::class);
}
+ // sanitize configuration template descriptions and help texts (since they allow HTML rendering)
+ protected function sanitizeTemplate(): void
+ {
+ $template = $this->configuration_template;
+
+ if (isset($template['custom_fields']) && is_array($template['custom_fields'])) {
+ foreach ($template['custom_fields'] as &$field) {
+ if (isset($field['description'])) {
+ $field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']);
+ }
+ if (isset($field['help_text'])) {
+ $field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']);
+ }
+ }
+
+ $this->configuration_template = $template;
+ }
+ }
+
public function hasMissingRequiredConfigurationFields(): bool
{
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
@@ -109,6 +136,11 @@ class Plugin extends Model
public function isDataStale(): bool
{
+ // Image webhook plugins don't use data staleness - images are pushed directly
+ if ($this->plugin_type === 'image_webhook') {
+ return false;
+ }
+
if ($this->data_strategy === 'webhook') {
// Treat as stale if any webhook event has occurred in the past hour
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
@@ -122,105 +154,67 @@ class Plugin extends Model
public function updateDataPayload(): void
{
- if ($this->data_strategy === 'polling' && $this->polling_url) {
-
- $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
-
- if ($this->polling_header) {
- // Resolve Liquid variables in the polling header
- $resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
- $headerLines = explode("\n", mb_trim($resolvedHeader));
- foreach ($headerLines as $line) {
- $parts = explode(':', $line, 2);
- if (count($parts) === 2) {
- $headers[mb_trim($parts[0])] = mb_trim($parts[1]);
- }
- }
- }
-
- // Resolve Liquid variables in the entire polling_url field first, then split by newline
- $resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
- $urls = array_filter(
- array_map('trim', explode("\n", $resolvedPollingUrls)),
- fn ($url): bool => ! empty($url)
- );
-
- // If only one URL, use the original logic without nesting
- if (count($urls) === 1) {
- $url = reset($urls);
- $httpRequest = Http::withHeaders($headers);
-
- if ($this->polling_verb === 'post' && $this->polling_body) {
- // Resolve Liquid variables in the polling body
- $resolvedBody = $this->resolveLiquidVariables($this->polling_body);
- $httpRequest = $httpRequest->withBody($resolvedBody);
- }
-
- // URL is already resolved, use it directly
- $resolvedUrl = $url;
-
- try {
- // Make the request based on the verb
- $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
-
- $response = $this->parseResponse($httpResponse);
-
- $this->update([
- 'data_payload' => $response,
- 'data_payload_updated_at' => now(),
- ]);
- } catch (Exception $e) {
- Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
- $this->update([
- 'data_payload' => ['error' => 'Failed to fetch data'],
- 'data_payload_updated_at' => now(),
- ]);
- }
-
- return;
- }
-
- // Multiple URLs - use nested response logic
- $combinedResponse = [];
-
- foreach ($urls as $index => $url) {
- $httpRequest = Http::withHeaders($headers);
-
- if ($this->polling_verb === 'post' && $this->polling_body) {
- // Resolve Liquid variables in the polling body
- $resolvedBody = $this->resolveLiquidVariables($this->polling_body);
- $httpRequest = $httpRequest->withBody($resolvedBody);
- }
-
- // URL is already resolved, use it directly
- $resolvedUrl = $url;
-
- try {
- // Make the request based on the verb
- $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
-
- $response = $this->parseResponse($httpResponse);
-
- // Check if response is an array at root level
- if (array_keys($response) === range(0, count($response) - 1)) {
- // Response is a sequential array, nest under .data
- $combinedResponse["IDX_{$index}"] = ['data' => $response];
- } else {
- // Response is an object or associative array, keep as is
- $combinedResponse["IDX_{$index}"] = $response;
- }
- } catch (Exception $e) {
- // Log error and continue with other URLs
- Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
- $combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
- }
- }
-
- $this->update([
- 'data_payload' => $combinedResponse,
- 'data_payload_updated_at' => now(),
- ]);
+ if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
+ return;
}
+ $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
+
+ // resolve headers
+ if ($this->polling_header) {
+ $resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
+ $headerLines = explode("\n", mb_trim($resolvedHeader));
+ foreach ($headerLines as $line) {
+ $parts = explode(':', $line, 2);
+ if (count($parts) === 2) {
+ $headers[mb_trim($parts[0])] = mb_trim($parts[1]);
+ }
+ }
+ }
+
+ // resolve and clean URLs
+ $resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
+ $urls = array_values(array_filter( // array_values ensures 0, 1, 2...
+ array_map('trim', explode("\n", $resolvedPollingUrls)),
+ fn ($url): bool => filled($url)
+ ));
+
+ $combinedResponse = [];
+
+ // Loop through all URLs (Handles 1 or many)
+ foreach ($urls as $index => $url) {
+ $httpRequest = Http::withHeaders($headers);
+
+ if ($this->polling_verb === 'post' && $this->polling_body) {
+ $resolvedBody = $this->resolveLiquidVariables($this->polling_body);
+ $httpRequest = $httpRequest->withBody($resolvedBody);
+ }
+
+ try {
+ $httpResponse = ($this->polling_verb === 'post')
+ ? $httpRequest->post($url)
+ : $httpRequest->get($url);
+
+ $response = $this->parseResponse($httpResponse);
+
+ // Nest if it's a sequential array
+ if (array_keys($response) === range(0, count($response) - 1)) {
+ $combinedResponse["IDX_{$index}"] = ['data' => $response];
+ } else {
+ $combinedResponse["IDX_{$index}"] = $response;
+ }
+ } catch (Exception $e) {
+ Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage());
+ $combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
+ }
+ }
+
+ // unwrap IDX_0 if only one URL
+ $finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse;
+
+ $this->update([
+ 'data_payload' => $finalPayload,
+ 'data_payload_updated_at' => now(),
+ ]);
}
private function parseResponse(Response $httpResponse): array
@@ -423,6 +417,10 @@ class Plugin extends Model
*/
public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string
{
+ if ($this->plugin_type !== 'recipe') {
+ throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
+ }
+
if ($this->render_markup) {
$renderedContent = '';
@@ -530,17 +528,30 @@ class Plugin extends Model
if ($this->render_markup_view) {
if ($standalone) {
- return view('trmnl-layouts.single', [
+ $renderedView = view($this->render_markup_view, [
+ 'size' => $size,
+ 'data' => $this->data_payload,
+ 'config' => $this->configuration ?? [],
+ ])->render();
+
+ if ($size === 'full') {
+ return view('trmnl-layouts.single', [
+ 'colorDepth' => $device?->colorDepth(),
+ 'deviceVariant' => $device?->deviceVariant() ?? 'og',
+ 'noBleed' => $this->no_bleed,
+ 'darkMode' => $this->dark_mode,
+ 'scaleLevel' => $device?->scaleLevel(),
+ 'slot' => $renderedView,
+ ])->render();
+ }
+
+ return view('trmnl-layouts.mashup', [
+ 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
- 'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
- 'slot' => view($this->render_markup_view, [
- 'size' => $size,
- 'data' => $this->data_payload,
- 'config' => $this->configuration ?? [],
- ])->render(),
+ 'slot' => $renderedView,
])->render();
}
@@ -571,4 +582,61 @@ class Plugin extends Model
default => '1Tx1B',
};
}
+
+ /**
+ * Duplicate the plugin, copying all attributes and handling render_markup_view
+ *
+ * @param int|null $userId Optional user ID for the duplicate. If not provided, uses the original plugin's user_id.
+ * @return Plugin The newly created duplicate plugin
+ */
+ public function duplicate(?int $userId = null): self
+ {
+ // Get all attributes except id and uuid
+ // Use toArray() to get cast values (respects JSON casts)
+ $attributes = $this->toArray();
+ unset($attributes['id'], $attributes['uuid']);
+
+ // Handle render_markup_view - copy file content to render_markup
+ if ($this->render_markup_view) {
+ try {
+ $basePath = resource_path('views/'.str_replace('.', '/', $this->render_markup_view));
+ $paths = [
+ $basePath.'.blade.php',
+ $basePath.'.liquid',
+ ];
+
+ $fileContent = null;
+ $markupLanguage = null;
+ foreach ($paths as $path) {
+ if (file_exists($path)) {
+ $fileContent = file_get_contents($path);
+ // Determine markup language based on file extension
+ $markupLanguage = str_ends_with($path, '.liquid') ? 'liquid' : 'blade';
+ break;
+ }
+ }
+
+ if ($fileContent !== null) {
+ $attributes['render_markup'] = $fileContent;
+ $attributes['markup_language'] = $markupLanguage;
+ $attributes['render_markup_view'] = null;
+ } else {
+ // File doesn't exist, remove the view reference
+ $attributes['render_markup_view'] = null;
+ }
+ } catch (Exception $e) {
+ // If file reading fails, remove the view reference
+ $attributes['render_markup_view'] = null;
+ }
+ }
+
+ // Append " (Copy)" to the name
+ $attributes['name'] = $this->name.' (Copy)';
+
+ // Set user_id - use provided userId or fall back to original plugin's user_id
+ $attributes['user_id'] = $userId ?? $this->user_id;
+
+ // Create and return the new plugin
+ return self::create($attributes);
+ }
}
diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php
index fcd5f12..405ea3f 100644
--- a/app/Services/ImageGenerationService.php
+++ b/app/Services/ImageGenerationService.php
@@ -26,11 +26,44 @@ class ImageGenerationService
public static function generateImage(string $markup, $deviceId): string
{
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
+ $uuid = self::generateImageFromModel(
+ markup: $markup,
+ deviceModel: $device->deviceModel,
+ user: $device->user,
+ palette: $device->palette ?? $device->deviceModel?->palette,
+ device: $device
+ );
+
+ $device->update(['current_screen_image' => $uuid]);
+ Log::info("Device $device->id: updated with new image: $uuid");
+
+ return $uuid;
+ }
+
+ /**
+ * Generate an image from markup using a DeviceModel
+ *
+ * @param string $markup The HTML markup to render
+ * @param DeviceModel|null $deviceModel The device model to use for image generation
+ * @param \App\Models\User|null $user Optional user for timezone settings
+ * @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette
+ * @param Device|null $device Optional device for legacy devices without DeviceModel
+ * @return string The UUID of the generated image
+ */
+ public static function generateImageFromModel(
+ string $markup,
+ ?DeviceModel $deviceModel = null,
+ ?\App\Models\User $user = null,
+ ?\App\Models\DevicePalette $palette = null,
+ ?Device $device = null
+ ): string {
$uuid = Uuid::uuid4()->toString();
try {
- // Get image generation settings from DeviceModel if available, otherwise use device settings
- $imageSettings = self::getImageSettings($device);
+ // Get image generation settings from DeviceModel or Device (for legacy devices)
+ $imageSettings = $deviceModel
+ ? self::getImageSettingsFromModel($deviceModel)
+ : ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null));
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
@@ -45,7 +78,7 @@ class ImageGenerationService
$browserStage->html($markup);
// Set timezone from user or fall back to app timezone
- $timezone = $device->user->timezone ?? config('app.timezone');
+ $timezone = $user?->timezone ?? config('app.timezone');
$browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') {
@@ -65,12 +98,12 @@ class ImageGenerationService
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
}
- // Get palette from device or fallback to device model's default palette
- $palette = $device->palette ?? $device->deviceModel?->palette;
+ // Get palette from parameter or fallback to device model's default palette
$colorPalette = null;
-
if ($palette && $palette->colors) {
$colorPalette = $palette->colors;
+ } elseif ($deviceModel?->palette && $deviceModel->palette->colors) {
+ $colorPalette = $deviceModel->palette->colors;
}
$imageStage = new ImageStage();
@@ -107,8 +140,7 @@ class ImageGenerationService
throw new RuntimeException('Image file is empty: '.$outputPath);
}
- $device->update(['current_screen_image' => $uuid]);
- Log::info("Device $device->id: updated with new image: $uuid");
+ Log::info("Generated image: $uuid");
return $uuid;
@@ -125,22 +157,7 @@ class ImageGenerationService
{
// If device has a DeviceModel, use its settings
if ($device->deviceModel) {
- /** @var DeviceModel $model */
- $model = $device->deviceModel;
-
- return [
- 'width' => $model->width,
- 'height' => $model->height,
- 'colors' => $model->colors,
- 'bit_depth' => $model->bit_depth,
- 'scale_factor' => $model->scale_factor,
- 'rotation' => $model->rotation,
- 'mime_type' => $model->mime_type,
- 'offset_x' => $model->offset_x,
- 'offset_y' => $model->offset_y,
- 'image_format' => self::determineImageFormatFromModel($model),
- 'use_model_settings' => true,
- ];
+ return self::getImageSettingsFromModel($device->deviceModel);
}
// Fallback to device settings
@@ -164,6 +181,43 @@ class ImageGenerationService
];
}
+ /**
+ * Get image generation settings from a DeviceModel
+ */
+ private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
+ {
+ if ($deviceModel) {
+ return [
+ 'width' => $deviceModel->width,
+ 'height' => $deviceModel->height,
+ 'colors' => $deviceModel->colors,
+ 'bit_depth' => $deviceModel->bit_depth,
+ 'scale_factor' => $deviceModel->scale_factor,
+ 'rotation' => $deviceModel->rotation,
+ 'mime_type' => $deviceModel->mime_type,
+ 'offset_x' => $deviceModel->offset_x,
+ 'offset_y' => $deviceModel->offset_y,
+ 'image_format' => self::determineImageFormatFromModel($deviceModel),
+ 'use_model_settings' => true,
+ ];
+ }
+
+ // Default settings if no device model provided
+ return [
+ 'width' => 800,
+ 'height' => 480,
+ 'colors' => 2,
+ 'bit_depth' => 1,
+ 'scale_factor' => 1.0,
+ 'rotation' => 0,
+ 'mime_type' => 'image/png',
+ 'offset_x' => 0,
+ 'offset_y' => 0,
+ 'image_format' => ImageFormat::AUTO->value,
+ 'use_model_settings' => false,
+ ];
+ }
+
/**
* Determine the appropriate ImageFormat based on DeviceModel settings
*/
@@ -280,6 +334,10 @@ class ImageGenerationService
public static function resetIfNotCacheable(?Plugin $plugin): void
{
if ($plugin?->id) {
+ // Image webhook plugins have finalized images that shouldn't be reset
+ if ($plugin->plugin_type === 'image_webhook') {
+ return;
+ }
// Check if any devices have custom dimensions or use non-standard DeviceModels
$hasCustomDimensions = Device::query()
->where(function ($query): void {
diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php
index 9207e3e..49dce99 100644
--- a/app/Services/PluginImportService.php
+++ b/app/Services/PluginImportService.php
@@ -17,6 +17,34 @@ use ZipArchive;
class PluginImportService
{
+ /**
+ * Validate YAML settings
+ *
+ * @param array $settings The parsed YAML settings
+ *
+ * @throws Exception
+ */
+ private function validateYAML(array $settings): void
+ {
+ if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
+ return;
+ }
+
+ foreach ($settings['custom_fields'] as $field) {
+ if (isset($field['field_type']) && $field['field_type'] === 'multi_string') {
+
+ if (isset($field['default']) && str_contains($field['default'], ',')) {
+ throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas.");
+ }
+
+ if (isset($field['placeholder']) && str_contains($field['placeholder'], ',')) {
+ throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas.");
+ }
+
+ }
+ }
+ }
+
/**
* Import a plugin from a ZIP file
*
@@ -47,32 +75,55 @@ class PluginImportService
$zip->extractTo($tempDir);
$zip->close();
- // Find the required files (settings.yml and full.liquid/full.blade.php)
+ // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
// Validate that we found the required files
- if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
- throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php
+ if (! $filePaths['settingsYamlPath']) {
+ throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
+ }
+
+ // Validate that we have at least one template file
+ if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
+ throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
}
// Parse settings.yml
$settingsYaml = File::get($filePaths['settingsYamlPath']);
$settings = Yaml::parse($settingsYaml);
+ $this->validateYAML($settings);
- // Read full.liquid content
- $fullLiquid = File::get($filePaths['fullLiquidPath']);
-
- // Prepend shared.liquid content if available
- if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
- $sharedLiquid = File::get($filePaths['sharedLiquidPath']);
- $fullLiquid = $sharedLiquid."\n".$fullLiquid;
- }
-
- // Check if the file ends with .liquid to set markup language
+ // Determine which template file to use and read its content
+ $templatePath = null;
$markupLanguage = 'blade';
- if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
+
+ if ($filePaths['fullLiquidPath']) {
+ $templatePath = $filePaths['fullLiquidPath'];
+ $fullLiquid = File::get($templatePath);
+
+ // Prepend shared.liquid or shared.blade.php content if available
+ if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
+ $sharedLiquid = File::get($filePaths['sharedLiquidPath']);
+ $fullLiquid = $sharedLiquid."\n".$fullLiquid;
+ } elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
+ $sharedBlade = File::get($filePaths['sharedBladePath']);
+ $fullLiquid = $sharedBlade."\n".$fullLiquid;
+ }
+
+ // Check if the file ends with .liquid to set markup language
+ if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
+ $markupLanguage = 'liquid';
+ $fullLiquid = '
'."\n".$fullLiquid."\n".'
';
+ }
+ } elseif ($filePaths['sharedLiquidPath']) {
+ $templatePath = $filePaths['sharedLiquidPath'];
+ $fullLiquid = File::get($templatePath);
$markupLanguage = 'liquid';
$fullLiquid = ''."\n".$fullLiquid."\n".'
';
+ } elseif ($filePaths['sharedBladePath']) {
+ $templatePath = $filePaths['sharedBladePath'];
+ $fullLiquid = File::get($templatePath);
+ $markupLanguage = 'blade';
}
// Ensure custom_fields is properly formatted
@@ -144,11 +195,12 @@ class PluginImportService
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
* @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid')
* @param string|null $iconUrl Optional icon URL to set on the plugin
+ * @param bool $allowDuplicate If true, generate a new UUID for trmnlp_id if a plugin with the same trmnlp_id already exists
* @return Plugin The created plugin instance
*
* @throws Exception If the ZIP file is invalid or required files are missing
*/
- public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin
+ public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin
{
// Download the ZIP file
$response = Http::timeout(60)->get($zipUrl);
@@ -176,32 +228,55 @@ class PluginImportService
$zip->extractTo($tempDir);
$zip->close();
- // Find the required files (settings.yml and full.liquid/full.blade.php)
+ // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
// Validate that we found the required files
- if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
- throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
+ if (! $filePaths['settingsYamlPath']) {
+ throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
+ }
+
+ // Validate that we have at least one template file
+ if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
+ throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
}
// Parse settings.yml
$settingsYaml = File::get($filePaths['settingsYamlPath']);
$settings = Yaml::parse($settingsYaml);
+ $this->validateYAML($settings);
- // Read full.liquid content
- $fullLiquid = File::get($filePaths['fullLiquidPath']);
-
- // Prepend shared.liquid content if available
- if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
- $sharedLiquid = File::get($filePaths['sharedLiquidPath']);
- $fullLiquid = $sharedLiquid."\n".$fullLiquid;
- }
-
- // Check if the file ends with .liquid to set markup language
+ // Determine which template file to use and read its content
+ $templatePath = null;
$markupLanguage = 'blade';
- if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
+
+ if ($filePaths['fullLiquidPath']) {
+ $templatePath = $filePaths['fullLiquidPath'];
+ $fullLiquid = File::get($templatePath);
+
+ // Prepend shared.liquid or shared.blade.php content if available
+ if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
+ $sharedLiquid = File::get($filePaths['sharedLiquidPath']);
+ $fullLiquid = $sharedLiquid."\n".$fullLiquid;
+ } elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
+ $sharedBlade = File::get($filePaths['sharedBladePath']);
+ $fullLiquid = $sharedBlade."\n".$fullLiquid;
+ }
+
+ // Check if the file ends with .liquid to set markup language
+ if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
+ $markupLanguage = 'liquid';
+ $fullLiquid = ''."\n".$fullLiquid."\n".'
';
+ }
+ } elseif ($filePaths['sharedLiquidPath']) {
+ $templatePath = $filePaths['sharedLiquidPath'];
+ $fullLiquid = File::get($templatePath);
$markupLanguage = 'liquid';
$fullLiquid = ''."\n".$fullLiquid."\n".'
';
+ } elseif ($filePaths['sharedBladePath']) {
+ $templatePath = $filePaths['sharedBladePath'];
+ $fullLiquid = File::get($templatePath);
+ $markupLanguage = 'blade';
}
// Ensure custom_fields is properly formatted
@@ -217,17 +292,26 @@ class PluginImportService
'custom_fields' => $settings['custom_fields'],
];
- $plugin_updated = isset($settings['id'])
+ // Determine the trmnlp_id to use
+ $trmnlpId = $settings['id'] ?? Uuid::v7();
+
+ // If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID
+ if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) {
+ $trmnlpId = Uuid::v7();
+ }
+
+ $plugin_updated = ! $allowDuplicate && isset($settings['id'])
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
+
// Create a new plugin
$plugin = Plugin::updateOrCreate(
[
- 'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
+ 'user_id' => $user->id, 'trmnlp_id' => $trmnlpId,
],
[
'user_id' => $user->id,
'name' => $settings['name'] ?? 'Imported Plugin',
- 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
+ 'trmnlp_id' => $trmnlpId,
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
'data_strategy' => $settings['strategy'] ?? 'static',
'polling_url' => $settings['polling_url'] ?? null,
@@ -272,6 +356,7 @@ class PluginImportService
$settingsYamlPath = null;
$fullLiquidPath = null;
$sharedLiquidPath = null;
+ $sharedBladePath = null;
// If zipEntryPath is specified, look for files in that specific directory first
if ($zipEntryPath) {
@@ -289,6 +374,8 @@ class PluginImportService
if (File::exists($targetDir.'/shared.liquid')) {
$sharedLiquidPath = $targetDir.'/shared.liquid';
+ } elseif (File::exists($targetDir.'/shared.blade.php')) {
+ $sharedBladePath = $targetDir.'/shared.blade.php';
}
}
@@ -304,15 +391,18 @@ class PluginImportService
if (File::exists($targetDir.'/src/shared.liquid')) {
$sharedLiquidPath = $targetDir.'/src/shared.liquid';
+ } elseif (File::exists($targetDir.'/src/shared.blade.php')) {
+ $sharedBladePath = $targetDir.'/src/shared.blade.php';
}
}
// If we found the required files in the target directory, return them
- if ($settingsYamlPath && $fullLiquidPath) {
+ if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
return [
'settingsYamlPath' => $settingsYamlPath,
'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath,
+ 'sharedBladePath' => $sharedBladePath,
];
}
}
@@ -329,9 +419,11 @@ class PluginImportService
$fullLiquidPath = $tempDir.'/src/full.blade.php';
}
- // Check for shared.liquid in the same directory
+ // Check for shared.liquid or shared.blade.php in the same directory
if (File::exists($tempDir.'/src/shared.liquid')) {
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
+ } elseif (File::exists($tempDir.'/src/shared.blade.php')) {
+ $sharedBladePath = $tempDir.'/src/shared.blade.php';
}
} else {
// Search for the files in the extracted directory structure
@@ -348,20 +440,24 @@ class PluginImportService
$fullLiquidPath = $filepath;
} elseif ($filename === 'shared.liquid') {
$sharedLiquidPath = $filepath;
+ } elseif ($filename === 'shared.blade.php') {
+ $sharedBladePath = $filepath;
}
}
- // Check if shared.liquid exists in the same directory as full.liquid
- if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath) {
+ // Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid
+ if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) {
$fullLiquidDir = dirname((string) $fullLiquidPath);
if (File::exists($fullLiquidDir.'/shared.liquid')) {
$sharedLiquidPath = $fullLiquidDir.'/shared.liquid';
+ } elseif (File::exists($fullLiquidDir.'/shared.blade.php')) {
+ $sharedBladePath = $fullLiquidDir.'/shared.blade.php';
}
}
// If we found the files but they're not in the src folder,
// check if they're in the root of the ZIP or in a subfolder
- if ($settingsYamlPath && $fullLiquidPath) {
+ if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
// If the files are in the root of the ZIP, create a src folder and move them there
$srcDir = dirname((string) $settingsYamlPath);
@@ -372,17 +468,25 @@ class PluginImportService
// Copy the files to the src directory
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
- File::copy($fullLiquidPath, $newSrcDir.'/full.liquid');
- // Copy shared.liquid if it exists
+ // Copy full.liquid or full.blade.php if it exists
+ if ($fullLiquidPath) {
+ $extension = pathinfo((string) $fullLiquidPath, PATHINFO_EXTENSION);
+ File::copy($fullLiquidPath, $newSrcDir.'/full.'.$extension);
+ $fullLiquidPath = $newSrcDir.'/full.'.$extension;
+ }
+
+ // Copy shared.liquid or shared.blade.php if it exists
if ($sharedLiquidPath) {
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
$sharedLiquidPath = $newSrcDir.'/shared.liquid';
+ } elseif ($sharedBladePath) {
+ File::copy($sharedBladePath, $newSrcDir.'/shared.blade.php');
+ $sharedBladePath = $newSrcDir.'/shared.blade.php';
}
// Update the paths
$settingsYamlPath = $newSrcDir.'/settings.yml';
- $fullLiquidPath = $newSrcDir.'/full.liquid';
}
}
}
@@ -391,6 +495,7 @@ class PluginImportService
'settingsYamlPath' => $settingsYamlPath,
'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath,
+ 'sharedBladePath' => $sharedBladePath,
];
}
diff --git a/boost.json b/boost.json
deleted file mode 100644
index 53962fa..0000000
--- a/boost.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "agents": [
- "claude_code",
- "copilot",
- "cursor",
- "phpstorm"
- ],
- "editors": [
- "claude_code",
- "cursor",
- "phpstorm",
- "vscode"
- ],
- "guidelines": []
-}
diff --git a/composer.json b/composer.json
index 2281415..0ced4da 100644
--- a/composer.json
+++ b/composer.json
@@ -6,6 +6,7 @@
"keywords": [
"trmnl",
"trmnl-server",
+ "trmnl-byos",
"laravel"
],
"license": "MIT",
@@ -14,7 +15,7 @@
"ext-imagick": "*",
"ext-simplexml": "*",
"ext-zip": "*",
- "bnussbau/laravel-trmnl-blade": "2.0.*",
+ "bnussbau/laravel-trmnl-blade": "2.1.*",
"bnussbau/trmnl-pipeline-php": "^0.6.0",
"keepsuit/laravel-liquid": "^0.5.2",
"laravel/framework": "^12.1",
@@ -25,6 +26,7 @@
"livewire/volt": "^1.7",
"om/icalparser": "^3.2",
"spatie/browsershot": "^5.0",
+ "stevebauman/purify": "^6.3",
"symfony/yaml": "^7.3",
"wnx/sidecar-browsershot": "^2.6"
},
diff --git a/composer.lock b/composer.lock
index 1b578bf..d23d014 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "3e4c22c016c04e49512b5fcd20983baa",
+ "content-hash": "25c2a1a4a2f2594adefe25ddb6a072fb",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -62,16 +62,16 @@
},
{
"name": "aws/aws-sdk-php",
- "version": "3.369.4",
+ "version": "3.369.10",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
- "reference": "2aa1ef195e90140d733382e4341732ce113024f5"
+ "reference": "e179090bf2d658be7be37afc146111966ba6f41b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2aa1ef195e90140d733382e4341732ce113024f5",
- "reference": "2aa1ef195e90140d733382e4341732ce113024f5",
+ "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e179090bf2d658be7be37afc146111966ba6f41b",
+ "reference": "e179090bf2d658be7be37afc146111966ba6f41b",
"shasum": ""
},
"require": {
@@ -153,22 +153,22 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
- "source": "https://github.com/aws/aws-sdk-php/tree/3.369.4"
+ "source": "https://github.com/aws/aws-sdk-php/tree/3.369.10"
},
- "time": "2025-12-29T19:07:47+00:00"
+ "time": "2026-01-09T19:08:12+00:00"
},
{
"name": "bnussbau/laravel-trmnl-blade",
- "version": "2.0.1",
+ "version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/bnussbau/laravel-trmnl-blade.git",
- "reference": "59343cfa9c41c7c7f9285b366584cde92bf1294e"
+ "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/59343cfa9c41c7c7f9285b366584cde92bf1294e",
- "reference": "59343cfa9c41c7c7f9285b366584cde92bf1294e",
+ "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/1e1cabfead00118d7a80c86ac6108aece2989bc7",
+ "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7",
"shasum": ""
},
"require": {
@@ -223,7 +223,7 @@
],
"support": {
"issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues",
- "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.0.1"
+ "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.1.0"
},
"funding": [
{
@@ -239,7 +239,7 @@
"type": "github"
}
],
- "time": "2025-09-22T12:12:00+00:00"
+ "time": "2026-01-02T20:38:51+00:00"
},
{
"name": "bnussbau/trmnl-pipeline-php",
@@ -815,17 +815,78 @@
"time": "2025-03-06T22:45:56+00:00"
},
{
- "name": "firebase/php-jwt",
- "version": "v6.11.1",
+ "name": "ezyang/htmlpurifier",
+ "version": "v4.19.0",
"source": {
"type": "git",
- "url": "https://github.com/firebase/php-jwt.git",
- "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66"
+ "url": "https://github.com/ezyang/htmlpurifier.git",
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
- "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
+ "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
+ "shasum": ""
+ },
+ "require": {
+ "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
+ },
+ "require-dev": {
+ "cerdic/css-tidy": "^1.7 || ^2.0",
+ "simpletest/simpletest": "dev-master"
+ },
+ "suggest": {
+ "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
+ "ext-bcmath": "Used for unit conversion and imagecrash protection",
+ "ext-iconv": "Converts text to and from non-UTF-8 encodings",
+ "ext-tidy": "Used for pretty-printing HTML"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "library/HTMLPurifier.composer.php"
+ ],
+ "psr-0": {
+ "HTMLPurifier": "library/"
+ },
+ "exclude-from-classmap": [
+ "/library/HTMLPurifier/Language/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Edward Z. Yang",
+ "email": "admin@htmlpurifier.org",
+ "homepage": "http://ezyang.com"
+ }
+ ],
+ "description": "Standards compliant HTML filter written in PHP",
+ "homepage": "http://htmlpurifier.org/",
+ "keywords": [
+ "html"
+ ],
+ "support": {
+ "issues": "https://github.com/ezyang/htmlpurifier/issues",
+ "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
+ },
+ "time": "2025-10-17T16:34:55+00:00"
+ },
+ {
+ "name": "firebase/php-jwt",
+ "version": "v7.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/firebase/php-jwt.git",
+ "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65",
+ "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"shasum": ""
},
"require": {
@@ -873,9 +934,9 @@
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
- "source": "https://github.com/firebase/php-jwt/tree/v6.11.1"
+ "source": "https://github.com/firebase/php-jwt/tree/v7.0.2"
},
- "time": "2025-04-09T20:32:01+00:00"
+ "time": "2025-12-16T22:17:28+00:00"
},
{
"name": "fruitcake/php-cors",
@@ -1617,16 +1678,16 @@
},
{
"name": "laravel/framework",
- "version": "v12.44.0",
+ "version": "v12.46.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "592bbf1c036042958332eb98e3e8131b29102f33"
+ "reference": "9dcff48d25a632c1fadb713024c952fec489c4ae"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/592bbf1c036042958332eb98e3e8131b29102f33",
- "reference": "592bbf1c036042958332eb98e3e8131b29102f33",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/9dcff48d25a632c1fadb713024c952fec489c4ae",
+ "reference": "9dcff48d25a632c1fadb713024c952fec489c4ae",
"shasum": ""
},
"require": {
@@ -1835,7 +1896,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2025-12-23T15:29:43+00:00"
+ "time": "2026-01-07T23:26:53+00:00"
},
{
"name": "laravel/prompts",
@@ -1898,16 +1959,16 @@
},
{
"name": "laravel/sanctum",
- "version": "v4.2.1",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
- "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664"
+ "reference": "fd447754d2d3f56950d53b930128af2e3b617de9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/sanctum/zipball/f5fb373be39a246c74a060f2cf2ae2c2145b3664",
- "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664",
+ "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd447754d2d3f56950d53b930128af2e3b617de9",
+ "reference": "fd447754d2d3f56950d53b930128af2e3b617de9",
"shasum": ""
},
"require": {
@@ -1957,7 +2018,7 @@
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
- "time": "2025-11-21T13:59:03+00:00"
+ "time": "2026-01-06T23:11:51+00:00"
},
{
"name": "laravel/serializable-closure",
@@ -2022,21 +2083,21 @@
},
{
"name": "laravel/socialite",
- "version": "v5.24.0",
+ "version": "v5.24.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/socialite.git",
- "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd"
+ "reference": "25e28c14d55404886777af1d77cf030e0f633142"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/socialite/zipball/1d19358c28e8951dde6e36603b89d8f09e6cfbfd",
- "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd",
+ "url": "https://api.github.com/repos/laravel/socialite/zipball/25e28c14d55404886777af1d77cf030e0f633142",
+ "reference": "25e28c14d55404886777af1d77cf030e0f633142",
"shasum": ""
},
"require": {
"ext-json": "*",
- "firebase/php-jwt": "^6.4",
+ "firebase/php-jwt": "^6.4|^7.0",
"guzzlehttp/guzzle": "^6.0|^7.0",
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
"illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
@@ -2090,20 +2151,20 @@
"issues": "https://github.com/laravel/socialite/issues",
"source": "https://github.com/laravel/socialite"
},
- "time": "2025-12-09T15:37:06+00:00"
+ "time": "2026-01-01T02:57:21+00:00"
},
{
"name": "laravel/tinker",
- "version": "v2.10.2",
+ "version": "v2.11.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/tinker.git",
- "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c"
+ "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/tinker/zipball/3bcb5f62d6f837e0f093a601e26badafb127bd4c",
- "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c",
+ "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468",
+ "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468",
"shasum": ""
},
"require": {
@@ -2112,7 +2173,7 @@
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
"php": "^7.2.5|^8.0",
"psy/psysh": "^0.11.1|^0.12.0",
- "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0"
+ "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "~1.3.3|^1.4.2",
@@ -2154,9 +2215,9 @@
],
"support": {
"issues": "https://github.com/laravel/tinker/issues",
- "source": "https://github.com/laravel/tinker/tree/v2.10.2"
+ "source": "https://github.com/laravel/tinker/tree/v2.11.0"
},
- "time": "2025-11-20T16:29:12+00:00"
+ "time": "2025-12-19T19:16:45+00:00"
},
{
"name": "league/commonmark",
@@ -3142,16 +3203,16 @@
},
{
"name": "monolog/monolog",
- "version": "3.9.0",
+ "version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
- "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6"
+ "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6",
- "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6",
+ "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
+ "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"shasum": ""
},
"require": {
@@ -3169,7 +3230,7 @@
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
- "mongodb/mongodb": "^1.8",
+ "mongodb/mongodb": "^1.8 || ^2.0",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
@@ -3229,7 +3290,7 @@
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
- "source": "https://github.com/Seldaek/monolog/tree/3.9.0"
+ "source": "https://github.com/Seldaek/monolog/tree/3.10.0"
},
"funding": [
{
@@ -3241,7 +3302,7 @@
"type": "tidelift"
}
],
- "time": "2025-03-24T10:02:05+00:00"
+ "time": "2026-01-02T08:56:05+00:00"
},
{
"name": "mtdowling/jmespath.php",
@@ -4947,6 +5008,72 @@
],
"time": "2025-01-13T13:04:43+00:00"
},
+ {
+ "name": "stevebauman/purify",
+ "version": "v6.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/stevebauman/purify.git",
+ "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/stevebauman/purify/zipball/3acb5e77904f420ce8aad8fa1c7f394e82daa500",
+ "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500",
+ "shasum": ""
+ },
+ "require": {
+ "ezyang/htmlpurifier": "^4.17",
+ "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0",
+ "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Purify": "Stevebauman\\Purify\\Facades\\Purify"
+ },
+ "providers": [
+ "Stevebauman\\Purify\\PurifyServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Stevebauman\\Purify\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Steve Bauman",
+ "email": "steven_bauman@outlook.com"
+ }
+ ],
+ "description": "An HTML Purifier / Sanitizer for Laravel",
+ "keywords": [
+ "Purifier",
+ "clean",
+ "cleaner",
+ "html",
+ "laravel",
+ "purification",
+ "purify"
+ ],
+ "support": {
+ "issues": "https://github.com/stevebauman/purify/issues",
+ "source": "https://github.com/stevebauman/purify/tree/v6.3.1"
+ },
+ "time": "2025-05-21T16:53:09+00:00"
+ },
{
"name": "symfony/clock",
"version": "v8.0.0",
@@ -5026,16 +5153,16 @@
},
{
"name": "symfony/console",
- "version": "v7.4.1",
+ "version": "v7.4.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e"
+ "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e",
- "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e",
+ "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6",
+ "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6",
"shasum": ""
},
"require": {
@@ -5100,7 +5227,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v7.4.1"
+ "source": "https://github.com/symfony/console/tree/v7.4.3"
},
"funding": [
{
@@ -5120,7 +5247,7 @@
"type": "tidelift"
}
],
- "time": "2025-12-05T15:23:39+00:00"
+ "time": "2025-12-23T14:50:43+00:00"
},
{
"name": "symfony/css-selector",
@@ -5573,16 +5700,16 @@
},
{
"name": "symfony/finder",
- "version": "v7.4.0",
+ "version": "v7.4.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "340b9ed7320570f319028a2cbec46d40535e94bd"
+ "reference": "fffe05569336549b20a1be64250b40516d6e8d06"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd",
- "reference": "340b9ed7320570f319028a2cbec46d40535e94bd",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06",
+ "reference": "fffe05569336549b20a1be64250b40516d6e8d06",
"shasum": ""
},
"require": {
@@ -5617,7 +5744,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v7.4.0"
+ "source": "https://github.com/symfony/finder/tree/v7.4.3"
},
"funding": [
{
@@ -5637,20 +5764,20 @@
"type": "tidelift"
}
],
- "time": "2025-11-05T05:42:40+00:00"
+ "time": "2025-12-23T14:50:43+00:00"
},
{
"name": "symfony/http-foundation",
- "version": "v7.4.1",
+ "version": "v7.4.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27"
+ "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27",
- "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52",
+ "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52",
"shasum": ""
},
"require": {
@@ -5699,7 +5826,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-foundation/tree/v7.4.1"
+ "source": "https://github.com/symfony/http-foundation/tree/v7.4.3"
},
"funding": [
{
@@ -5719,20 +5846,20 @@
"type": "tidelift"
}
],
- "time": "2025-12-07T11:13:10+00:00"
+ "time": "2025-12-23T14:23:49+00:00"
},
{
"name": "symfony/http-kernel",
- "version": "v7.4.2",
+ "version": "v7.4.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
- "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f"
+ "reference": "885211d4bed3f857b8c964011923528a55702aa5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f",
- "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f",
+ "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5",
+ "reference": "885211d4bed3f857b8c964011923528a55702aa5",
"shasum": ""
},
"require": {
@@ -5818,7 +5945,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-kernel/tree/v7.4.2"
+ "source": "https://github.com/symfony/http-kernel/tree/v7.4.3"
},
"funding": [
{
@@ -5838,20 +5965,20 @@
"type": "tidelift"
}
],
- "time": "2025-12-08T07:43:37+00:00"
+ "time": "2025-12-31T08:43:57+00:00"
},
{
"name": "symfony/mailer",
- "version": "v7.4.0",
+ "version": "v7.4.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
- "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd"
+ "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mailer/zipball/a3d9eea8cfa467ece41f0f54ba28185d74bd53fd",
- "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd",
+ "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4",
+ "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4",
"shasum": ""
},
"require": {
@@ -5902,7 +6029,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/mailer/tree/v7.4.0"
+ "source": "https://github.com/symfony/mailer/tree/v7.4.3"
},
"funding": [
{
@@ -5922,7 +6049,7 @@
"type": "tidelift"
}
],
- "time": "2025-11-21T15:26:00+00:00"
+ "time": "2025-12-16T08:02:06+00:00"
},
{
"name": "symfony/mime",
@@ -6844,16 +6971,16 @@
},
{
"name": "symfony/process",
- "version": "v7.4.0",
+ "version": "v7.4.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8"
+ "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8",
- "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8",
+ "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f",
+ "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f",
"shasum": ""
},
"require": {
@@ -6885,7 +7012,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v7.4.0"
+ "source": "https://github.com/symfony/process/tree/v7.4.3"
},
"funding": [
{
@@ -6905,20 +7032,20 @@
"type": "tidelift"
}
],
- "time": "2025-10-16T11:21:06+00:00"
+ "time": "2025-12-19T10:00:43+00:00"
},
{
"name": "symfony/routing",
- "version": "v7.4.0",
+ "version": "v7.4.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
- "reference": "4720254cb2644a0b876233d258a32bf017330db7"
+ "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/routing/zipball/4720254cb2644a0b876233d258a32bf017330db7",
- "reference": "4720254cb2644a0b876233d258a32bf017330db7",
+ "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090",
+ "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090",
"shasum": ""
},
"require": {
@@ -6970,7 +7097,7 @@
"url"
],
"support": {
- "source": "https://github.com/symfony/routing/tree/v7.4.0"
+ "source": "https://github.com/symfony/routing/tree/v7.4.3"
},
"funding": [
{
@@ -6990,7 +7117,7 @@
"type": "tidelift"
}
],
- "time": "2025-11-27T13:27:24+00:00"
+ "time": "2025-12-19T10:00:43+00:00"
},
{
"name": "symfony/service-contracts",
@@ -7171,16 +7298,16 @@
},
{
"name": "symfony/translation",
- "version": "v8.0.1",
+ "version": "v8.0.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
- "reference": "770e3b8b0ba8360958abedcabacd4203467333ca"
+ "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation/zipball/770e3b8b0ba8360958abedcabacd4203467333ca",
- "reference": "770e3b8b0ba8360958abedcabacd4203467333ca",
+ "url": "https://api.github.com/repos/symfony/translation/zipball/60a8f11f0e15c48f2cc47c4da53873bb5b62135d",
+ "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d",
"shasum": ""
},
"require": {
@@ -7240,7 +7367,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/translation/tree/v8.0.1"
+ "source": "https://github.com/symfony/translation/tree/v8.0.3"
},
"funding": [
{
@@ -7260,7 +7387,7 @@
"type": "tidelift"
}
],
- "time": "2025-12-01T09:13:36+00:00"
+ "time": "2025-12-21T10:59:45+00:00"
},
{
"name": "symfony/translation-contracts",
@@ -7424,16 +7551,16 @@
},
{
"name": "symfony/var-dumper",
- "version": "v7.4.0",
+ "version": "v7.4.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
- "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece"
+ "reference": "7e99bebcb3f90d8721890f2963463280848cba92"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece",
- "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece",
+ "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92",
+ "reference": "7e99bebcb3f90d8721890f2963463280848cba92",
"shasum": ""
},
"require": {
@@ -7487,7 +7614,7 @@
"dump"
],
"support": {
- "source": "https://github.com/symfony/var-dumper/tree/v7.4.0"
+ "source": "https://github.com/symfony/var-dumper/tree/v7.4.3"
},
"funding": [
{
@@ -7507,7 +7634,7 @@
"type": "tidelift"
}
],
- "time": "2025-10-27T20:36:44+00:00"
+ "time": "2025-12-18T07:04:31+00:00"
},
{
"name": "symfony/var-exporter",
@@ -7969,16 +8096,16 @@
"packages-dev": [
{
"name": "brianium/paratest",
- "version": "v7.16.0",
+ "version": "v7.16.1",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
- "reference": "a10878ed0fe0bbc2f57c980f7a08065338b970b6"
+ "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/paratestphp/paratest/zipball/a10878ed0fe0bbc2f57c980f7a08065338b970b6",
- "reference": "a10878ed0fe0bbc2f57c980f7a08065338b970b6",
+ "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b",
+ "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b",
"shasum": ""
},
"require": {
@@ -7989,10 +8116,10 @@
"fidry/cpu-core-counter": "^1.3.0",
"jean85/pretty-package-versions": "^2.1.1",
"php": "~8.3.0 || ~8.4.0 || ~8.5.0",
- "phpunit/php-code-coverage": "^12.5.1",
+ "phpunit/php-code-coverage": "^12.5.2",
"phpunit/php-file-iterator": "^6",
"phpunit/php-timer": "^8",
- "phpunit/phpunit": "^12.5.2",
+ "phpunit/phpunit": "^12.5.4",
"sebastian/environment": "^8.0.3",
"symfony/console": "^7.3.4 || ^8.0.0",
"symfony/process": "^7.3.4 || ^8.0.0"
@@ -8004,7 +8131,7 @@
"ext-posix": "*",
"phpstan/phpstan": "^2.1.33",
"phpstan/phpstan-deprecation-rules": "^2.0.3",
- "phpstan/phpstan-phpunit": "^2.0.10",
+ "phpstan/phpstan-phpunit": "^2.0.11",
"phpstan/phpstan-strict-rules": "^2.0.7",
"symfony/filesystem": "^7.3.2 || ^8.0.0"
},
@@ -8046,7 +8173,7 @@
],
"support": {
"issues": "https://github.com/paratestphp/paratest/issues",
- "source": "https://github.com/paratestphp/paratest/tree/v7.16.0"
+ "source": "https://github.com/paratestphp/paratest/tree/v7.16.1"
},
"funding": [
{
@@ -8058,7 +8185,7 @@
"type": "paypal"
}
],
- "time": "2025-12-09T20:03:26+00:00"
+ "time": "2026-01-08T07:23:06+00:00"
},
{
"name": "doctrine/deprecations",
@@ -8547,16 +8674,16 @@
},
{
"name": "laravel/boost",
- "version": "v1.8.7",
+ "version": "v1.8.9",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
- "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c"
+ "reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/boost/zipball/7a5709a8134ed59d3e7f34fccbd74689830e296c",
- "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c",
+ "url": "https://api.github.com/repos/laravel/boost/zipball/1f2c2d41b5216618170fb6730ec13bf894c5bffd",
+ "reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd",
"shasum": ""
},
"require": {
@@ -8609,20 +8736,20 @@
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
- "time": "2025-12-19T15:04:12+00:00"
+ "time": "2026-01-07T18:43:11+00:00"
},
{
"name": "laravel/mcp",
- "version": "v0.5.1",
+ "version": "v0.5.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
- "reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4"
+ "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/mcp/zipball/10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4",
- "reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/b9bdd8d6f8b547c8733fe6826b1819341597ba3c",
+ "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c",
"shasum": ""
},
"require": {
@@ -8682,7 +8809,7 @@
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
- "time": "2025-12-17T06:14:23+00:00"
+ "time": "2025-12-19T19:32:34+00:00"
},
{
"name": "laravel/pail",
@@ -8765,16 +8892,16 @@
},
{
"name": "laravel/pint",
- "version": "v1.26.0",
+ "version": "v1.27.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
- "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f"
+ "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f",
- "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90",
+ "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90",
"shasum": ""
},
"require": {
@@ -8785,9 +8912,9 @@
"php": "^8.2.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.90.0",
- "illuminate/view": "^12.40.1",
- "larastan/larastan": "^3.8.0",
+ "friendsofphp/php-cs-fixer": "^3.92.4",
+ "illuminate/view": "^12.44.0",
+ "larastan/larastan": "^3.8.1",
"laravel-zero/framework": "^12.0.4",
"mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.3.3",
@@ -8828,7 +8955,7 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
- "time": "2025-11-25T21:15:52+00:00"
+ "time": "2026-01-05T16:49:17+00:00"
},
{
"name": "laravel/roster",
@@ -8893,16 +9020,16 @@
},
{
"name": "laravel/sail",
- "version": "v1.51.0",
+ "version": "v1.52.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sail.git",
- "reference": "1c74357df034e869250b4365dd445c9f6ba5d068"
+ "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/sail/zipball/1c74357df034e869250b4365dd445c9f6ba5d068",
- "reference": "1c74357df034e869250b4365dd445c9f6ba5d068",
+ "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3",
+ "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3",
"shasum": ""
},
"require": {
@@ -8952,7 +9079,7 @@
"issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail"
},
- "time": "2025-12-09T13:33:49+00:00"
+ "time": "2026-01-01T02:46:03+00:00"
},
{
"name": "mockery/mockery",
@@ -9198,16 +9325,16 @@
},
{
"name": "pestphp/pest",
- "version": "v4.2.0",
+ "version": "v4.3.1",
"source": {
"type": "git",
"url": "https://github.com/pestphp/pest.git",
- "reference": "7c43c1c5834435ed9f4ad635e9cb1f0064f876bd"
+ "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pestphp/pest/zipball/7c43c1c5834435ed9f4ad635e9cb1f0064f876bd",
- "reference": "7c43c1c5834435ed9f4ad635e9cb1f0064f876bd",
+ "url": "https://api.github.com/repos/pestphp/pest/zipball/bc57a84e77afd4544ff9643a6858f68d05aeab96",
+ "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96",
"shasum": ""
},
"require": {
@@ -9219,12 +9346,12 @@
"pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1",
"php": "^8.3.0",
- "phpunit/phpunit": "^12.5.3",
- "symfony/process": "^7.4.0|^8.0.0"
+ "phpunit/phpunit": "^12.5.4",
+ "symfony/process": "^7.4.3|^8.0.0"
},
"conflict": {
"filp/whoops": "<2.18.3",
- "phpunit/phpunit": ">12.5.3",
+ "phpunit/phpunit": ">12.5.4",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@@ -9232,7 +9359,7 @@
"pestphp/pest-dev-tools": "^4.0.0",
"pestphp/pest-plugin-browser": "^4.1.1",
"pestphp/pest-plugin-type-coverage": "^4.0.3",
- "psy/psysh": "^0.12.17"
+ "psy/psysh": "^0.12.18"
},
"bin": [
"bin/pest"
@@ -9298,7 +9425,7 @@
],
"support": {
"issues": "https://github.com/pestphp/pest/issues",
- "source": "https://github.com/pestphp/pest/tree/v4.2.0"
+ "source": "https://github.com/pestphp/pest/tree/v4.3.1"
},
"funding": [
{
@@ -9310,7 +9437,7 @@
"type": "github"
}
],
- "time": "2025-12-15T11:49:28+00:00"
+ "time": "2026-01-04T16:29:59+00:00"
},
{
"name": "pestphp/pest-plugin",
@@ -10456,16 +10583,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "12.5.3",
+ "version": "12.5.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "6dc2e076d09960efbb0c1272aa9bc156fc80955e"
+ "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6dc2e076d09960efbb0c1272aa9bc156fc80955e",
- "reference": "6dc2e076d09960efbb0c1272aa9bc156fc80955e",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ba0e923f9d3fc655de22f9547c01d15a41fc93a",
+ "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a",
"shasum": ""
},
"require": {
@@ -10533,7 +10660,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.3"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.4"
},
"funding": [
{
@@ -10557,7 +10684,7 @@
"type": "tidelift"
}
],
- "time": "2025-12-11T08:52:59+00:00"
+ "time": "2025-12-15T06:05:34+00:00"
},
{
"name": "rector/rector",
@@ -11679,16 +11806,16 @@
},
{
"name": "webmozart/assert",
- "version": "2.0.0",
+ "version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
- "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54"
+ "reference": "bdbabc199a7ba9965484e4725d66170e5711323b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webmozarts/assert/zipball/1b34b004e35a164bc5bb6ebd33c844b2d8069a54",
- "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/bdbabc199a7ba9965484e4725d66170e5711323b",
+ "reference": "bdbabc199a7ba9965484e4725d66170e5711323b",
"shasum": ""
},
"require": {
@@ -11735,9 +11862,9 @@
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
- "source": "https://github.com/webmozarts/assert/tree/2.0.0"
+ "source": "https://github.com/webmozarts/assert/tree/2.1.1"
},
- "time": "2025-12-16T21:36:00+00:00"
+ "time": "2026-01-08T11:28:40+00:00"
}
],
"aliases": [],
diff --git a/config/trustedproxy.php b/config/trustedproxy.php
new file mode 100644
index 0000000..8557288
--- /dev/null
+++ b/config/trustedproxy.php
@@ -0,0 +1,6 @@
+ ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)),
+];
diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php
index a2d2e65..10a1580 100644
--- a/database/factories/PluginFactory.php
+++ b/database/factories/PluginFactory.php
@@ -29,8 +29,24 @@ class PluginFactory extends Factory
'icon_url' => null,
'flux_icon_name' => null,
'author_name' => $this->faker->name(),
+ 'plugin_type' => 'recipe',
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];
}
+
+ /**
+ * Indicate that the plugin is an image webhook plugin.
+ */
+ public function imageWebhook(): static
+ {
+ return $this->state(fn (array $attributes): array => [
+ 'plugin_type' => 'image_webhook',
+ 'data_strategy' => 'static',
+ 'data_stale_minutes' => 60,
+ 'polling_url' => null,
+ 'polling_verb' => 'get',
+ 'name' => $this->faker->randomElement(['Camera Feed', 'Security Camera', 'Webcam', 'Image Stream']),
+ ]);
+ }
}
diff --git a/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php b/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php
new file mode 100644
index 0000000..558fe2c
--- /dev/null
+++ b/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php
@@ -0,0 +1,28 @@
+string('plugin_type')->default('recipe')->after('uuid');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('plugins', function (Blueprint $table): void {
+ $table->dropColumn('plugin_type');
+ });
+ }
+};
diff --git a/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php b/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php
new file mode 100644
index 0000000..d230657
--- /dev/null
+++ b/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php
@@ -0,0 +1,33 @@
+string('kind')->nullable()->index();
+ });
+
+ // Set existing og_png and og_plus to kind "trmnl"
+ DeviceModel::whereIn('name', ['og_png', 'og_plus'])->update(['kind' => 'trmnl']);
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('device_models', function (Blueprint $table) {
+ $table->dropIndex(['kind']);
+ $table->dropColumn('kind');
+ });
+ }
+};
diff --git a/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php b/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php
new file mode 100644
index 0000000..9769505
--- /dev/null
+++ b/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php
@@ -0,0 +1,60 @@
+select('user_id', 'trmnlp_id', DB::raw('COUNT(*) as count'))
+ ->whereNotNull('trmnlp_id')
+ ->groupBy('user_id', 'trmnlp_id')
+ ->having('count', '>', 1)
+ ->get();
+
+ // For each duplicate combination, keep the first one (by id) and set others to null
+ foreach ($duplicates as $duplicate) {
+ $plugins = DB::table('plugins')
+ ->where('user_id', $duplicate->user_id)
+ ->where('trmnlp_id', $duplicate->trmnlp_id)
+ ->orderBy('id')
+ ->get();
+
+ // Keep the first one, set the rest to null
+ $keepFirst = true;
+ foreach ($plugins as $plugin) {
+ if ($keepFirst) {
+ $keepFirst = false;
+
+ continue;
+ }
+
+ DB::table('plugins')
+ ->where('id', $plugin->id)
+ ->update(['trmnlp_id' => null]);
+ }
+ }
+
+ Schema::table('plugins', function (Blueprint $table) {
+ $table->unique(['user_id', 'trmnlp_id']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('plugins', function (Blueprint $table) {
+ $table->dropUnique(['user_id', 'trmnlp_id']);
+ });
+ }
+};
diff --git a/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php b/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php
new file mode 100644
index 0000000..0a527d7
--- /dev/null
+++ b/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php
@@ -0,0 +1,28 @@
+boolean('alias')->default(false);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('plugins', function (Blueprint $table) {
+ $table->dropColumn('alias');
+ });
+ }
+};
diff --git a/package-lock.json b/package-lock.json
index 8411d6a..e722432 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "laravel-trmnl-server",
+ "name": "laravel",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -156,7 +156,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@@ -193,7 +192,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@@ -215,7 +213,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -718,7 +715,6 @@
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@lezer/common": "^1.3.0"
}
@@ -1614,7 +1610,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -1898,8 +1893,7 @@
"version": "0.0.1521046",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
- "license": "BSD-3-Clause",
- "peer": true
+ "license": "BSD-3-Clause"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -2951,7 +2945,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -2978,7 +2971,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3429,7 +3421,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
diff --git a/public/mirror/assets/apple-touch-icon-120x120.png b/public/mirror/assets/apple-touch-icon-120x120.png
new file mode 100644
index 0000000..5e51318
Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-120x120.png differ
diff --git a/public/mirror/assets/apple-touch-icon-152x152.png b/public/mirror/assets/apple-touch-icon-152x152.png
new file mode 100644
index 0000000..9f8d9e3
Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-152x152.png differ
diff --git a/public/mirror/assets/apple-touch-icon-167x167.png b/public/mirror/assets/apple-touch-icon-167x167.png
new file mode 100644
index 0000000..79d1211
Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-167x167.png differ
diff --git a/public/mirror/assets/apple-touch-icon-180x180.png b/public/mirror/assets/apple-touch-icon-180x180.png
new file mode 100644
index 0000000..0499ff4
Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-180x180.png differ
diff --git a/public/mirror/assets/apple-touch-icon-76x76.png b/public/mirror/assets/apple-touch-icon-76x76.png
new file mode 100644
index 0000000..df3943a
Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-76x76.png differ
diff --git a/public/mirror/assets/favicon-16x16.png b/public/mirror/assets/favicon-16x16.png
new file mode 100644
index 0000000..b36f23b
Binary files /dev/null and b/public/mirror/assets/favicon-16x16.png differ
diff --git a/public/mirror/assets/favicon-32x32.png b/public/mirror/assets/favicon-32x32.png
new file mode 100644
index 0000000..ae12e60
Binary files /dev/null and b/public/mirror/assets/favicon-32x32.png differ
diff --git a/public/mirror/assets/favicon.ico b/public/mirror/assets/favicon.ico
new file mode 100644
index 0000000..da17cd5
Binary files /dev/null and b/public/mirror/assets/favicon.ico differ
diff --git a/public/mirror/assets/logo--brand.svg b/public/mirror/assets/logo--brand.svg
new file mode 100644
index 0000000..1b84f50
--- /dev/null
+++ b/public/mirror/assets/logo--brand.svg
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/mirror/index.html b/public/mirror/index.html
new file mode 100644
index 0000000..64746fe
--- /dev/null
+++ b/public/mirror/index.html
@@ -0,0 +1,521 @@
+
+
+
+
+
+ TRMNL BYOS Laravel Mirror
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/mirror/manifest.json b/public/mirror/manifest.json
new file mode 100644
index 0000000..4d44e44
--- /dev/null
+++ b/public/mirror/manifest.json
@@ -0,0 +1,7 @@
+{
+ "name": "TRMNL BYOS Laravel Mirror",
+ "short_name": "TRMNL BYOS",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#ffffff"
+}
diff --git a/resources/css/app.css b/resources/css/app.css
index 46b9ca1..de95b81 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -59,6 +59,10 @@
@apply !mb-0 !leading-tight;
}
+[data-flux-description] a {
+ @apply text-accent underline hover:opacity-80;
+}
+
input:focus[data-flux-control],
textarea:focus[data-flux-control],
select:focus[data-flux-control] {
@@ -68,3 +72,39 @@ select:focus[data-flux-control] {
/* \[:where(&)\]:size-4 {
@apply size-4;
} */
+
+@layer components {
+ /* standard container for app */
+ .styled-container,
+ .tab-button {
+ @apply rounded-xl border bg-white dark:bg-stone-950 dark:border-zinc-700 text-stone-800 shadow-xs;
+ }
+
+ .tab-button {
+ @apply flex items-center gap-2 px-4 py-2 text-sm font-medium;
+ @apply rounded-b-none shadow-none bg-inherit;
+
+ /* This makes the button sit slightly over the box border */
+ margin-bottom: -1px;
+ position: relative;
+ z-index: 1;
+ }
+
+ .tab-button.is-active {
+ @apply text-zinc-700 dark:text-zinc-300;
+ @apply border-b-white dark:border-b-zinc-800;
+
+ /* Z-index 10 ensures the bottom border of the tab hides the top border of the box */
+ z-index: 10;
+ }
+
+ .tab-button:not(.is-active) {
+ @apply text-zinc-500 border-transparent;
+ }
+
+ .tab-button:not(.is-active):hover {
+ @apply text-zinc-700 dark:text-zinc-300;
+ @apply border-zinc-300 dark:border-zinc-700;
+ cursor: pointer;
+ }
+}
diff --git a/resources/js/codemirror-core.js b/resources/js/codemirror-core.js
index c77bf3d..f23389f 100644
--- a/resources/js/codemirror-core.js
+++ b/resources/js/codemirror-core.js
@@ -1,8 +1,9 @@
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
import { ViewPlugin } from '@codemirror/view';
-import { indentWithTab } from '@codemirror/commands';
+import { indentWithTab, selectAll } from '@codemirror/commands';
import { foldGutter, foldKeymap } from '@codemirror/language';
import { history, historyKeymap } from '@codemirror/commands';
+import { searchKeymap } from '@codemirror/search';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
@@ -154,7 +155,16 @@ export function createCodeMirror(element, options = {}) {
createResizePlugin(),
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
...themeSupport,
- keymap.of([indentWithTab, ...foldKeymap, ...historyKeymap]),
+ keymap.of([
+ indentWithTab,
+ ...foldKeymap,
+ ...historyKeymap,
+ ...searchKeymap,
+ {
+ key: 'Mod-a',
+ run: selectAll,
+ },
+ ]),
EditorView.theme({
'&': {
fontSize: '14px',
diff --git a/resources/views/components/layouts/auth/card.blade.php b/resources/views/components/layouts/auth/card.blade.php
index 1a316ef..b5a62c6 100644
--- a/resources/views/components/layouts/auth/card.blade.php
+++ b/resources/views/components/layouts/auth/card.blade.php
@@ -15,7 +15,7 @@
-
diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php
index 7257ab0..fdf7f34 100644
--- a/resources/views/livewire/catalog/index.blade.php
+++ b/resources/views/livewire/catalog/index.blade.php
@@ -113,7 +113,8 @@ class extends Component
auth()->user(),
$plugin['zip_entry_path'] ?? null,
null,
- $plugin['logo_url'] ?? null
+ $plugin['logo_url'] ?? null,
+ allowDuplicate: true
);
$this->dispatch('plugin-installed');
diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php
index 9ecad1a..cc8b070 100644
--- a/resources/views/livewire/catalog/trmnl.blade.php
+++ b/resources/views/livewire/catalog/trmnl.blade.php
@@ -164,7 +164,8 @@ class extends Component
auth()->user(),
null,
config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null,
- $recipe['icon_url'] ?? null
+ $recipe['icon_url'] ?? null,
+ allowDuplicate: true
);
$this->dispatch('plugin-installed');
diff --git a/resources/views/livewire/device-dashboard.blade.php b/resources/views/livewire/device-dashboard.blade.php
index 5db65d1..7fd48a8 100644
--- a/resources/views/livewire/device-dashboard.blade.php
+++ b/resources/views/livewire/device-dashboard.blade.php
@@ -16,7 +16,7 @@ new class extends Component {
@if($devices->isEmpty())
+ class="styled-container">
Add your first device
+ class="styled-container">
@php
$current_image_uuid =$device->current_screen_image;
diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php
index 30b4481..f9d49ca 100644
--- a/resources/views/livewire/devices/configure.blade.php
+++ b/resources/views/livewire/devices/configure.blade.php
@@ -309,7 +309,7 @@ new class extends Component {
+ class="styled-container">
@php
$current_image_uuid =$device->current_screen_image;
@@ -368,6 +368,10 @@ new class extends Component {
Update Firmware
Show Logs
+
+ Mirror URL
+
+
Delete Device
@@ -498,6 +502,26 @@ new class extends Component {
+
+ @php
+ $mirrorUrl = url('/mirror/index.html') . '?mac_address=' . urlencode($device->mac_address) . '&api_key=' . urlencode($device->api_key);
+ @endphp
+
+
+
+ Mirror WebUI
+ Mirror this device onto older devices with a web browser — Safari is supported back to iOS 9.
+
+
+
+
+
+
@if(!$device->mirror_device_id)
@if($current_image_path)
diff --git a/resources/views/livewire/playlists/index.blade.php b/resources/views/livewire/playlists/index.blade.php
index 3e786b4..6c979e6 100644
--- a/resources/views/livewire/playlists/index.blade.php
+++ b/resources/views/livewire/playlists/index.blade.php
@@ -332,7 +332,7 @@ new class extends Component {
@endforeach
@if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty()))
-
+
No playlists found
Add playlists to your devices to see them here.
diff --git a/resources/views/livewire/plugins/config-modal.blade.php b/resources/views/livewire/plugins/config-modal.blade.php
new file mode 100644
index 0000000..7aaacbb
--- /dev/null
+++ b/resources/views/livewire/plugins/config-modal.blade.php
@@ -0,0 +1,516 @@
+ loadData();
+ }
+
+ public function loadData(): void
+ {
+ $this->resetErrorBag();
+ // Reload data
+ $this->plugin = $this->plugin->fresh();
+
+ $this->configuration_template = $this->plugin->configuration_template ?? [];
+ $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : [];
+
+ // Initialize multiValues by exploding the CSV strings from the DB
+ foreach ($this->configuration_template['custom_fields'] ?? [] as $field) {
+ if (($field['field_type'] ?? null) === 'multi_string') {
+ $fieldKey = $field['keyname'];
+ $rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? '');
+
+ $currentValue = is_array($rawValue) ? '' : (string)$rawValue;
+
+ $this->multiValues[$fieldKey] = $currentValue !== ''
+ ? array_values(array_filter(explode(',', $currentValue)))
+ : [''];
+ }
+ }
+ }
+
+ /**
+ * Triggered by @close on the modal to discard any typed but unsaved changes
+ */
+ public int $resetIndex = 0; // Add this property
+ public function resetForm(): void
+ {
+ $this->loadData();
+ $this->resetIndex++; // Increment to force DOM refresh
+ }
+
+ public function saveConfiguration()
+ {
+ abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
+
+ // final validation layer
+ $this->validate([
+ 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'],
+ ], [
+ 'multiValues.*.*.regex' => 'Items cannot contain commas.',
+ ]);
+
+ // Prepare config copy to send to db
+ $finalValues = $this->configuration;
+ foreach ($this->configuration_template['custom_fields'] ?? [] as $field) {
+ $fieldKey = $field['keyname'];
+
+ // Handle multi_string: Join array back to CSV string
+ if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) {
+ $finalValues[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey])));
+ }
+
+ // Handle code fields: Try to JSON decode if necessary (standard TRMNL behavior)
+ if ($field['field_type'] === 'code' && isset($finalValues[$fieldKey]) && is_string($finalValues[$fieldKey])) {
+ $decoded = json_decode($finalValues[$fieldKey], true);
+ if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) {
+ $finalValues[$fieldKey] = $decoded;
+ }
+ }
+ }
+
+ // send to db
+ $this->plugin->update(['configuration' => $finalValues]);
+ $this->configuration = $finalValues; // update local state
+ $this->dispatch('config-updated'); // notifies listeners
+ Flux::modal('configuration-modal')->close();
+ }
+
+ // ------------------------------------This section contains helper functions for interacting with the form------------------------------------------------
+ public function addMultiItem(string $fieldKey): void
+ {
+ $this->multiValues[$fieldKey][] = '';
+ }
+
+ public function removeMultiItem(string $fieldKey, int $index): void
+ {
+ unset($this->multiValues[$fieldKey][$index]);
+
+ $this->multiValues[$fieldKey] = array_values($this->multiValues[$fieldKey]);
+
+ if (empty($this->multiValues[$fieldKey])) {
+ $this->multiValues[$fieldKey][] = '';
+ }
+ }
+
+ // Livewire magic method to validate MultiValue input boxes
+ // Runs on every debounce
+ public function updatedMultiValues($value, $key)
+ {
+ $this->validate([
+ 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'],
+ ], [
+ 'multiValues.*.*.regex' => 'Items cannot contain commas.',
+ ]);
+ }
+
+ public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void
+ {
+ abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
+
+ try {
+ $requestData = [];
+ if ($query !== null) {
+ $requestData = [
+ 'function' => $fieldKey,
+ 'query' => $query
+ ];
+ }
+
+ $response = $query !== null
+ ? Http::post($endpoint, $requestData)
+ : Http::post($endpoint);
+
+ if ($response->successful()) {
+ $this->xhrSelectOptions[$fieldKey] = $response->json();
+ } else {
+ $this->xhrSelectOptions[$fieldKey] = [];
+ }
+ } catch (\Exception $e) {
+ $this->xhrSelectOptions[$fieldKey] = [];
+ }
+ }
+
+ public function searchXhrSelect(string $fieldKey, string $endpoint): void
+ {
+ $query = $this->searchQueries[$fieldKey] ?? '';
+ if (!empty($query)) {
+ $this->loadXhrSelectOptions($fieldKey, $endpoint, $query);
+ }
+ }
+};?>
+
+
+
+
+
+ Configuration
+ Configure your plugin settings
+
+
+
+
+
+
diff --git a/resources/views/livewire/plugins/image-webhook-instance.blade.php b/resources/views/livewire/plugins/image-webhook-instance.blade.php
new file mode 100644
index 0000000..e4ad9df
--- /dev/null
+++ b/resources/views/livewire/plugins/image-webhook-instance.blade.php
@@ -0,0 +1,298 @@
+user()->plugins->contains($this->plugin), 403);
+ abort_unless($this->plugin->plugin_type === 'image_webhook', 404);
+
+ $this->name = $this->plugin->name;
+ }
+
+ protected array $rules = [
+ 'name' => 'required|string|max:255',
+ 'checked_devices' => 'array',
+ 'device_playlist_names' => 'array',
+ 'device_playlists' => 'array',
+ 'device_weekdays' => 'array',
+ 'device_active_from' => 'array',
+ 'device_active_until' => 'array',
+ ];
+
+ public function updateName(): void
+ {
+ abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
+ $this->validate(['name' => 'required|string|max:255']);
+ $this->plugin->update(['name' => $this->name]);
+ }
+
+
+ public function addToPlaylist()
+ {
+ $this->validate([
+ 'checked_devices' => 'required|array|min:1',
+ ]);
+
+ foreach ($this->checked_devices as $deviceId) {
+ if (!isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) {
+ $this->addError('device_playlists.' . $deviceId, 'Please select a playlist for each device.');
+ return;
+ }
+
+ if ($this->device_playlists[$deviceId] === 'new') {
+ if (!isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) {
+ $this->addError('device_playlist_names.' . $deviceId, 'Playlist name is required when creating a new playlist.');
+ return;
+ }
+ }
+ }
+
+ foreach ($this->checked_devices as $deviceId) {
+ $playlist = null;
+
+ if ($this->device_playlists[$deviceId] === 'new') {
+ $playlist = \App\Models\Playlist::create([
+ 'device_id' => $deviceId,
+ 'name' => $this->device_playlist_names[$deviceId],
+ 'weekdays' => !empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null,
+ 'active_from' => $this->device_active_from[$deviceId] ?? null,
+ 'active_until' => $this->device_active_until[$deviceId] ?? null,
+ ]);
+ } else {
+ $playlist = \App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]);
+ }
+
+ $maxOrder = $playlist->items()->max('order') ?? 0;
+
+ // Image webhook plugins only support full layout
+ $playlist->items()->create([
+ 'plugin_id' => $this->plugin->id,
+ 'order' => $maxOrder + 1,
+ ]);
+ }
+
+ $this->reset([
+ 'checked_devices',
+ 'device_playlists',
+ 'device_playlist_names',
+ 'device_weekdays',
+ 'device_active_from',
+ 'device_active_until',
+ ]);
+ Flux::modal('add-to-playlist')->close();
+ }
+
+ public function getDevicePlaylists($deviceId)
+ {
+ return \App\Models\Playlist::where('device_id', $deviceId)->get();
+ }
+
+ public function hasAnyPlaylistSelected(): bool
+ {
+ foreach ($this->checked_devices as $deviceId) {
+ if (isset($this->device_playlists[$deviceId]) && !empty($this->device_playlists[$deviceId])) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function deletePlugin(): void
+ {
+ abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
+ $this->plugin->delete();
+ $this->redirect(route('plugins.image-webhook'));
+ }
+
+ public function getImagePath(): ?string
+ {
+ if (!$this->plugin->current_image) {
+ return null;
+ }
+
+ $extensions = ['png', 'bmp'];
+ foreach ($extensions as $ext) {
+ $path = 'images/generated/'.$this->plugin->current_image.'.'.$ext;
+ if (\Illuminate\Support\Facades\Storage::disk('public')->exists($path)) {
+ return $path;
+ }
+ }
+
+ return null;
+ }
+};
+?>
+
+
+
+
+
Image Webhook – {{$plugin->name}}
+
+
+
+ Add to Playlist
+
+
+
+
+
+
+ Delete Instance
+
+
+
+
+
+
+
+
+
+ Add to Playlist
+
+
+
+
+
+
+
+
+
Delete {{ $plugin->name }}?
+
This will also remove this instance from your playlists.
+
+
+
+
+
+ Cancel
+
+ Delete instance
+
+
+
+
+
+
+
+
+ Webhook URL
+
+ POST an image (PNG or BMP) to this URL to update the displayed image.
+
+
+ Images must be posted in a format that can directly be read by the device. You need to take care of image format, dithering, and bit-depth. Check device logs if the image is not shown.
+
+
+
+
+
+
+
+
Current Image
+ @if($this->getImagePath())
+
+ @else
+
+ No image uploaded yet. POST an image to the webhook URL to get started.
+
+ @endif
+
+
+
+
+
+
diff --git a/resources/views/livewire/plugins/image-webhook.blade.php b/resources/views/livewire/plugins/image-webhook.blade.php
new file mode 100644
index 0000000..3161443
--- /dev/null
+++ b/resources/views/livewire/plugins/image-webhook.blade.php
@@ -0,0 +1,163 @@
+ 'required|string|max:255',
+ ];
+
+ public function mount(): void
+ {
+ $this->refreshInstances();
+ }
+
+ public function refreshInstances(): void
+ {
+ $this->instances = auth()->user()
+ ->plugins()
+ ->where('plugin_type', 'image_webhook')
+ ->orderBy('created_at', 'desc')
+ ->get()
+ ->toArray();
+ }
+
+ public function createInstance(): void
+ {
+ abort_unless(auth()->user() !== null, 403);
+ $this->validate();
+
+ Plugin::create([
+ 'uuid' => Str::uuid(),
+ 'user_id' => auth()->id(),
+ 'name' => $this->name,
+ 'plugin_type' => 'image_webhook',
+ 'data_strategy' => 'static', // Not used for image_webhook, but required
+ 'data_stale_minutes' => 60, // Not used for image_webhook, but required
+ ]);
+
+ $this->reset(['name']);
+ $this->refreshInstances();
+
+ Flux::modal('create-instance')->close();
+ }
+
+ public function deleteInstance(int $pluginId): void
+ {
+ abort_unless(auth()->user() !== null, 403);
+
+ $plugin = Plugin::where('id', $pluginId)
+ ->where('user_id', auth()->id())
+ ->where('plugin_type', 'image_webhook')
+ ->firstOrFail();
+
+ $plugin->delete();
+ $this->refreshInstances();
+ }
+};
+?>
+
+
+
+
+
Image Webhook
+ Plugin
+
+
+ Create Instance
+
+
+
+
+
+
+ Create Image Webhook Instance
+ Create a new instance that accepts images via webhook
+
+
+
+
+
+
+ @if(empty($instances))
+
+
+ No instances yet
+ Create your first Image Webhook instance to get started.
+
+
+ @else
+
+
+
+
+ Name
+
+
+ Actions
+
+
+
+
+
+ @foreach($instances as $instance)
+
+
+ {{ $instance['name'] }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @endforeach
+
+
+ @endif
+
+ @foreach($instances as $instance)
+
+
+
Delete {{ $instance['name'] }}?
+
This will also remove this instance from your playlists.
+
+
+
+
+
+ Cancel
+
+ Delete instance
+
+
+ @endforeach
+
+
+
diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php
index 469365c..d902183 100644
--- a/resources/views/livewire/plugins/index.blade.php
+++ b/resources/views/livewire/plugins/index.blade.php
@@ -26,6 +26,8 @@ new class extends Component {
['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'],
'api' =>
['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'],
+ 'image-webhook' =>
+ ['name' => 'Image Webhook', 'flux_icon_name' => 'photo', 'detail_view_route' => 'plugins.image-webhook'],
];
protected $rules = [
@@ -40,7 +42,12 @@ new class extends Component {
public function refreshPlugins(): void
{
- $userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray();
+ // Only show recipe plugins in the main list (image_webhook has its own management page)
+ $userPlugins = auth()->user()?->plugins()
+ ->where('plugin_type', 'recipe')
+ ->get()
+ ->makeHidden(['render_markup', 'data_payload'])
+ ->toArray();
$allPlugins = array_merge($this->native_plugins, $userPlugins ?? []);
$allPlugins = array_values($allPlugins);
$allPlugins = $this->sortPlugins($allPlugins);
@@ -388,7 +395,7 @@ new class extends Component {
wire:key="plugin-{{ $plugin['id'] ?? $plugin['name'] ?? $index }}"
x-data="{ pluginName: {{ json_encode(strtolower($plugin['name'] ?? '')) }} }"
x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())"
- class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
+ class="styled-container">
diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php
index 4be96cc..1597d5d 100644
--- a/resources/views/livewire/plugins/recipe.blade.php
+++ b/resources/views/livewire/plugins/recipe.blade.php
@@ -1,12 +1,16 @@
user()->plugins->contains($this->plugin), 403);
$this->blade_code = $this->plugin->render_markup;
+ // required to render some stuff
$this->configuration_template = $this->plugin->configuration_template ?? [];
- $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : [];
if ($this->plugin->render_markup_view) {
try {
@@ -74,6 +77,12 @@ new class extends Component {
$this->fillformFields();
$this->data_payload_updated_at = $this->plugin->data_payload_updated_at;
+
+ // Set default preview device model
+ if ($this->preview_device_model_id === null) {
+ $defaultModel = DeviceModel::where('name', 'og_plus')->first() ?? DeviceModel::first();
+ $this->preview_device_model_id = $defaultModel?->id;
+ }
}
public function fillFormFields(): void
@@ -129,6 +138,19 @@ new class extends Component {
$validated = $this->validate();
$validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
$this->plugin->update($validated);
+
+ foreach ($this->configuration_template as $fieldKey => $field) {
+ if (($field['field_type'] ?? null) !== 'multi_string') {
+ continue;
+ }
+
+ if (!isset($this->multiValues[$fieldKey])) {
+ continue;
+ }
+
+ $validated[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey])));
+ }
+
}
protected function validatePollingUrl(): void
@@ -254,39 +276,6 @@ new class extends Component {
Flux::modal('add-to-playlist')->close();
}
- public function saveConfiguration()
- {
- abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
-
- $configurationValues = [];
- if (isset($this->configuration_template['custom_fields'])) {
- foreach ($this->configuration_template['custom_fields'] as $field) {
- $fieldKey = $field['keyname'];
- if (isset($this->configuration[$fieldKey])) {
- $value = $this->configuration[$fieldKey];
-
- // For code fields, if the value is a JSON string and the original was an array, decode it
- if ($field['field_type'] === 'code' && is_string($value)) {
- $decoded = json_decode($value, true);
- // If it's valid JSON and decodes to an array/object, use the decoded value
- // Otherwise, keep the string as-is
- if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) {
- $value = $decoded;
- }
- }
-
- $configurationValues[$fieldKey] = $value;
- }
- }
- }
-
- $this->plugin->update([
- 'configuration' => $configurationValues
- ]);
-
- Flux::modal('configuration-modal')->close();
- }
-
public function getDevicePlaylists($deviceId)
{
return \App\Models\Playlist::where('device_id', $deviceId)->get();
@@ -307,8 +296,6 @@ new class extends Component {
return $this->configuration[$key] ?? $default;
}
-
-
public function renderExample(string $example)
{
switch ($example) {
@@ -377,13 +364,17 @@ HTML;
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
+ $this->preview_size = $size;
+
// If data strategy is polling and data_payload is null, fetch the data first
if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) {
$this->updateData();
}
try {
- $previewMarkup = $this->plugin->render($size);
+ // Create a device object with og_plus model and the selected bitdepth
+ $device = $this->createPreviewDevice();
+ $previewMarkup = $this->plugin->render($size, true, $device);
$this->dispatch('preview-updated', preview: $previewMarkup);
} catch (LiquidException $e) {
$this->dispatch('preview-error', message: $e->toLiquidErrorMessage());
@@ -392,6 +383,38 @@ HTML;
}
}
+ private function createPreviewDevice(): \App\Models\Device
+ {
+ $deviceModel = DeviceModel::with(['palette'])->find($this->preview_device_model_id)
+ ?? DeviceModel::with(['palette'])->first();
+
+ $device = new Device();
+ $device->setRelation('deviceModel', $deviceModel);
+
+ return $device;
+ }
+
+ public function getDeviceModels()
+ {
+ return DeviceModel::whereKind('trmnl')->orderBy('label')->get();
+ }
+
+ public function updatedPreviewDeviceModelId(): void
+ {
+ $this->renderPreview($this->preview_size);
+ }
+
+ public function duplicatePlugin(): void
+ {
+ abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
+
+ // Use the model's duplicate method
+ $newPlugin = $this->plugin->duplicate(auth()->id());
+
+ // Redirect to the new plugin's detail page
+ $this->redirect(route('plugins.recipe', ['plugin' => $newPlugin]));
+ }
+
public function deletePlugin(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
@@ -399,42 +422,31 @@ HTML;
$this->redirect(route('plugins.index'));
}
- public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void
- {
- abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
+ #[On('config-updated')]
+ public function refreshPlugin()
+ {
+ // This pulls the fresh 'configuration' from the DB
+ // and re-triggers the @if check in the Blade template
+ $this->plugin = $this->plugin->fresh();
+ }
- try {
- $requestData = [];
- if ($query !== null) {
- $requestData = [
- 'function' => $fieldKey,
- 'query' => $query
- ];
- }
-
- $response = $query !== null
- ? Http::post($endpoint, $requestData)
- : Http::post($endpoint);
-
- if ($response->successful()) {
- $this->xhrSelectOptions[$fieldKey] = $response->json();
- } else {
- $this->xhrSelectOptions[$fieldKey] = [];
- }
- } catch (\Exception $e) {
- $this->xhrSelectOptions[$fieldKey] = [];
- }
+ // Laravel Livewire computed property: access with $this->parsed_urls
+ #[Computed]
+ private function parsedUrls()
+ {
+ if (!isset($this->polling_url)) {
+ return null;
}
- public function searchXhrSelect(string $fieldKey, string $endpoint): void
- {
- $query = $this->searchQueries[$fieldKey] ?? '';
- if (!empty($query)) {
- $this->loadXhrSelectOptions($fieldKey, $endpoint, $query);
- }
+ try {
+ return $this->plugin->resolveLiquidVariables($this->polling_url);
+
+ } catch (\Exception $e) {
+ return 'PARSE_ERROR: ' . $e->getMessage();
}
+ }
+
}
-
?>
@@ -466,7 +478,6 @@ HTML;
-
@@ -476,6 +487,11 @@ HTML;
+
+ Recipe Settings
+
+
+ Duplicate Plugin
Delete Plugin
@@ -617,8 +633,15 @@ HTML;
-
+
Preview {{ $plugin->name }}
+
+
+ @foreach($this->getDeviceModels() as $model)
+ {{ $model->label ?? $model->name }}
+ @endforeach
+
+
@@ -626,269 +649,9 @@ HTML;
-
-
-
- Configuration
- Configure your plugin settings
-
+
-
-
-
+
Settings
@@ -976,7 +739,7 @@ HTML;
@endif
- Configuration
+ Configuration Fields
@endif
@@ -989,15 +752,62 @@ HTML;
@if($data_strategy === 'polling')
-
-
Polling URL
+
+
+
+
+
+ Settings
+
+
+
+
+ Preview URL
+
+
+
+
+
+
+ Enter the URL(s) to poll for data:
+
-
-
+ rows="5"
+ />
+
+ {!! 'Hint: Supports multiple requests via line break separation. You can also use configuration variables with Liquid syntax . ' !!}
+
+
+
+
+
+
+ Preview computed URLs here (readonly):
+
+ {{ $this->parsed_urls }}
+
+
+
+
+
Fetch data now
+
@@ -1161,9 +971,6 @@ HTML;
-
-
-
@else
diff --git a/resources/views/livewire/plugins/recipes/settings.blade.php b/resources/views/livewire/plugins/recipes/settings.blade.php
new file mode 100644
index 0000000..8ae3d6f
--- /dev/null
+++ b/resources/views/livewire/plugins/recipes/settings.blade.php
@@ -0,0 +1,104 @@
+resetErrorBag();
+ // Reload data
+ $this->plugin = $this->plugin->fresh();
+ $this->trmnlp_id = $this->plugin->trmnlp_id;
+ $this->uuid = $this->plugin->uuid;
+ $this->alias = $this->plugin->alias ?? false;
+ }
+
+ public function saveTrmnlpId(): void
+ {
+ abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
+
+ $this->validate([
+ 'trmnlp_id' => [
+ 'nullable',
+ 'string',
+ 'max:255',
+ Rule::unique('plugins', 'trmnlp_id')
+ ->where('user_id', auth()->id())
+ ->ignore($this->plugin->id),
+ ],
+ 'alias' => 'boolean',
+ ]);
+
+ $this->plugin->update([
+ 'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id,
+ 'alias' => $this->alias,
+ ]);
+
+ Flux::modal('trmnlp-settings')->close();
+ }
+
+ public function getAliasUrlProperty(): string
+ {
+ return url("/api/display/{$this->uuid}/alias");
+ }
+};?>
+
+
+
+
+ Recipe Settings
+
+
+
+
+
diff --git a/resources/views/recipes/zen.blade.php b/resources/views/recipes/zen.blade.php
index 5e01eac..0ae920f 100644
--- a/resources/views/recipes/zen.blade.php
+++ b/resources/views/recipes/zen.blade.php
@@ -3,11 +3,11 @@
- {{$data[0]['a']}}
- @if (strlen($data[0]['q']) < 300 && $size != 'quadrant')
- {{ $data[0]['q'] }}
+ {{$data['data'][0]['a'] ?? ''}}
+ @if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant')
+ {{ $data['data'][0]['q'] ?? '' }}
@else
- {{ $data[0]['q'] }}
+ {{ $data['data'][0]['q'] ?? '' }}
@endif
diff --git a/routes/api.php b/routes/api.php
index b1d08b4..f3a31a1 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -549,6 +549,91 @@ Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) {
return response()->json(['message' => 'Data updated successfully']);
})->name('api.custom_plugins.webhook');
+Route::post('plugin_settings/{uuid}/image', function (Request $request, string $uuid) {
+ $plugin = Plugin::where('uuid', $uuid)->firstOrFail();
+
+ // Check if plugin is image_webhook type
+ if ($plugin->plugin_type !== 'image_webhook') {
+ return response()->json(['error' => 'Plugin is not an image webhook plugin'], 400);
+ }
+
+ // Accept image from either multipart form or raw binary
+ $image = null;
+ $extension = null;
+
+ if ($request->hasFile('image')) {
+ $file = $request->file('image');
+ $extension = mb_strtolower($file->getClientOriginalExtension());
+ $image = $file->get();
+ } elseif ($request->has('image')) {
+ // Base64 encoded image
+ $imageData = $request->input('image');
+ if (preg_match('/^data:image\/(\w+);base64,/', $imageData, $matches)) {
+ $extension = mb_strtolower($matches[1]);
+ $image = base64_decode(mb_substr($imageData, mb_strpos($imageData, ',') + 1));
+ } else {
+ return response()->json(['error' => 'Invalid image format. Expected base64 data URI.'], 400);
+ }
+ } else {
+ // Try raw binary
+ $image = $request->getContent();
+ $contentType = $request->header('Content-Type', '');
+ $trimmedContent = mb_trim($image);
+
+ // Check if content is empty or just empty JSON
+ if (empty($image) || $trimmedContent === '' || $trimmedContent === '{}') {
+ return response()->json(['error' => 'No image data provided'], 400);
+ }
+
+ // If it's a JSON request without image field, return error
+ if (str_contains($contentType, 'application/json')) {
+ return response()->json(['error' => 'No image data provided'], 400);
+ }
+
+ // Detect image type from content
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ $mimeType = finfo_buffer($finfo, $image);
+ finfo_close($finfo);
+
+ $extension = match ($mimeType) {
+ 'image/png' => 'png',
+ 'image/bmp' => 'bmp',
+ default => null,
+ };
+
+ if (! $extension) {
+ return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400);
+ }
+ }
+
+ // Validate extension
+ $allowedExtensions = ['png', 'bmp'];
+ if (! in_array($extension, $allowedExtensions)) {
+ return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400);
+ }
+
+ // Generate a new UUID for each image upload to prevent device caching
+ $imageUuid = Str::uuid()->toString();
+ $filename = $imageUuid.'.'.$extension;
+ $path = 'images/generated/'.$filename;
+
+ // Save image to storage
+ Storage::disk('public')->put($path, $image);
+
+ // Update plugin's current_image field with the new UUID
+ $plugin->update([
+ 'current_image' => $imageUuid,
+ ]);
+
+ // Clean up old images
+ ImageGenerationService::cleanupFolder();
+
+ return response()->json([
+ 'message' => 'Image uploaded successfully',
+ 'image_url' => url('storage/'.$path),
+ ]);
+})->name('api.plugin_settings.image');
+
Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) {
if (! $trmnlp_id || mb_trim($trmnlp_id) === '') {
return response()->json([
@@ -593,3 +678,90 @@ Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, s
],
]);
})->middleware('auth:sanctum');
+
+Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
+ $plugin = Plugin::where('uuid', $uuid)->firstOrFail();
+
+ // Check if alias is active
+ if (! $plugin->alias) {
+ return response()->json([
+ 'message' => 'Alias is not active for this plugin',
+ ], 403);
+ }
+
+ // Get device model name from query parameter, default to 'og_png'
+ $deviceModelName = $request->query('device-model', 'og_png');
+ $deviceModel = DeviceModel::where('name', $deviceModelName)->first();
+
+ if (! $deviceModel) {
+ return response()->json([
+ 'message' => "Device model '{$deviceModelName}' not found",
+ ], 404);
+ }
+
+ // Check if we can use cached image (only for og_png and if data is not stale)
+ $useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null;
+
+ if ($useCache) {
+ // Return cached image
+ $imageUuid = $plugin->current_image;
+ $fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
+ $imagePath = 'images/generated/'.$imageUuid.'.'.$fileExtension;
+
+ // Check if image exists, otherwise fall back to generation
+ if (Storage::disk('public')->exists($imagePath)) {
+ return response()->file(Storage::disk('public')->path($imagePath), [
+ 'Content-Type' => $deviceModel->mime_type,
+ ]);
+ }
+ }
+
+ // Generate new image
+ try {
+ // Update data if needed
+ if ($plugin->isDataStale()) {
+ $plugin->updateDataPayload();
+ $plugin->refresh();
+ }
+
+ // Load device model with palette relationship
+ $deviceModel->load('palette');
+
+ // Create a virtual device for rendering (Plugin::render needs a Device object)
+ $virtualDevice = new Device();
+ $virtualDevice->setRelation('deviceModel', $deviceModel);
+ $virtualDevice->setRelation('user', $plugin->user);
+ $virtualDevice->setRelation('palette', $deviceModel->palette);
+
+ // Render the plugin markup
+ $markup = $plugin->render(device: $virtualDevice);
+
+ // Generate image using the new method that doesn't require a device
+ $imageUuid = ImageGenerationService::generateImageFromModel(
+ markup: $markup,
+ deviceModel: $deviceModel,
+ user: $plugin->user,
+ palette: $deviceModel->palette
+ );
+
+ // Update plugin cache if using og_png
+ if ($deviceModelName === 'og_png') {
+ $plugin->update(['current_image' => $imageUuid]);
+ }
+
+ // Return the generated image
+ $fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
+ $imagePath = Storage::disk('public')->path('images/generated/'.$imageUuid.'.'.$fileExtension);
+
+ return response()->file($imagePath, [
+ 'Content-Type' => $deviceModel->mime_type,
+ ]);
+ } catch (Exception $e) {
+ Log::error("Failed to generate alias image for plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
+
+ return response()->json([
+ 'message' => 'Failed to generate image',
+ 'error' => $e->getMessage(),
+ ], 500);
+ }
+})->name('api.display.alias');
diff --git a/routes/web.php b/routes/web.php
index 7b7868d..b3069bd 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -31,6 +31,8 @@ Route::middleware(['auth'])->group(function () {
Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe');
Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
Volt::route('plugins/api', 'plugins.api')->name('plugins.api');
+ Volt::route('plugins/image-webhook', 'plugins.image-webhook')->name('plugins.image-webhook');
+ Volt::route('plugins/image-webhook/{plugin}', 'plugins.image-webhook-instance')->name('plugins.image-webhook-instance');
Volt::route('playlists', 'playlists.index')->name('playlists.index');
Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) {
diff --git a/tests/Feature/Api/ImageWebhookTest.php b/tests/Feature/Api/ImageWebhookTest.php
new file mode 100644
index 0000000..121f90a
--- /dev/null
+++ b/tests/Feature/Api/ImageWebhookTest.php
@@ -0,0 +1,196 @@
+makeDirectory('/images/generated');
+});
+
+test('can upload image to image webhook plugin via multipart form', function (): void {
+ $user = User::factory()->create();
+ $plugin = Plugin::factory()->imageWebhook()->create([
+ 'user_id' => $user->id,
+ ]);
+
+ $image = UploadedFile::fake()->image('test.png', 800, 480);
+
+ $response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [
+ 'image' => $image,
+ ]);
+
+ $response->assertOk()
+ ->assertJsonStructure([
+ 'message',
+ 'image_url',
+ ]);
+
+ $plugin->refresh();
+ expect($plugin->current_image)
+ ->not->toBeNull()
+ ->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
+
+ // File should exist with the new UUID
+ Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
+
+ // Image URL should contain the new UUID
+ expect($response->json('image_url'))
+ ->toContain($plugin->current_image);
+});
+
+test('can upload image to image webhook plugin via raw binary', function (): void {
+ $user = User::factory()->create();
+ $plugin = Plugin::factory()->imageWebhook()->create([
+ 'user_id' => $user->id,
+ ]);
+
+ // Create a simple PNG image binary
+ $pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
+
+ $response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
+ 'CONTENT_TYPE' => 'image/png',
+ ], $pngData);
+
+ $response->assertOk()
+ ->assertJsonStructure([
+ 'message',
+ 'image_url',
+ ]);
+
+ $plugin->refresh();
+ expect($plugin->current_image)
+ ->not->toBeNull()
+ ->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
+
+ // File should exist with the new UUID
+ Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
+
+ // Image URL should contain the new UUID
+ expect($response->json('image_url'))
+ ->toContain($plugin->current_image);
+});
+
+test('can upload image to image webhook plugin via base64 data URI', function (): void {
+ $user = User::factory()->create();
+ $plugin = Plugin::factory()->imageWebhook()->create([
+ 'user_id' => $user->id,
+ ]);
+
+ // Create a simple PNG image as base64 data URI
+ $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
+
+ $response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", [
+ 'image' => $base64Image,
+ ]);
+
+ $response->assertOk()
+ ->assertJsonStructure([
+ 'message',
+ 'image_url',
+ ]);
+
+ $plugin->refresh();
+ expect($plugin->current_image)
+ ->not->toBeNull()
+ ->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
+
+ // File should exist with the new UUID
+ Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
+
+ // Image URL should contain the new UUID
+ expect($response->json('image_url'))
+ ->toContain($plugin->current_image);
+});
+
+test('returns 400 for non-image-webhook plugin', function (): void {
+ $user = User::factory()->create();
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'plugin_type' => 'recipe',
+ ]);
+
+ $image = UploadedFile::fake()->image('test.png', 800, 480);
+
+ $response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [
+ 'image' => $image,
+ ]);
+
+ $response->assertStatus(400)
+ ->assertJson(['error' => 'Plugin is not an image webhook plugin']);
+});
+
+test('returns 404 for non-existent plugin', function (): void {
+ $image = UploadedFile::fake()->image('test.png', 800, 480);
+
+ $response = $this->post('/api/plugin_settings/'.Str::uuid().'/image', [
+ 'image' => $image,
+ ]);
+
+ $response->assertNotFound();
+});
+
+test('returns 400 for unsupported image format', function (): void {
+ $user = User::factory()->create();
+ $plugin = Plugin::factory()->imageWebhook()->create([
+ 'user_id' => $user->id,
+ ]);
+
+ // Create a fake GIF file (not supported)
+ $gifData = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
+
+ $response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
+ 'CONTENT_TYPE' => 'image/gif',
+ ], $gifData);
+
+ $response->assertStatus(400)
+ ->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']);
+});
+
+test('returns 400 for JPG image format', function (): void {
+ $user = User::factory()->create();
+ $plugin = Plugin::factory()->imageWebhook()->create([
+ 'user_id' => $user->id,
+ ]);
+
+ // Create a fake JPG file (not supported)
+ $jpgData = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A');
+
+ $response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
+ 'CONTENT_TYPE' => 'image/jpeg',
+ ], $jpgData);
+
+ $response->assertStatus(400)
+ ->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']);
+});
+
+test('returns 400 when no image data provided', function (): void {
+ $user = User::factory()->create();
+ $plugin = Plugin::factory()->imageWebhook()->create([
+ 'user_id' => $user->id,
+ ]);
+
+ $response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", []);
+
+ $response->assertStatus(400)
+ ->assertJson(['error' => 'No image data provided']);
+});
+
+test('image webhook plugin isDataStale returns false', function (): void {
+ $plugin = Plugin::factory()->imageWebhook()->create();
+
+ expect($plugin->isDataStale())->toBeFalse();
+});
+
+test('image webhook plugin factory creates correct plugin type', function (): void {
+ $plugin = Plugin::factory()->imageWebhook()->create();
+
+ expect($plugin)
+ ->plugin_type->toBe('image_webhook')
+ ->data_strategy->toBe('static');
+});
diff --git a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php
index 7674d7f..f0be135 100644
--- a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php
+++ b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php
@@ -44,6 +44,7 @@ test('fetch device models job handles successful api response', function (): voi
'mime_type' => 'image/png',
'offset_x' => 0,
'offset_y' => 0,
+ 'kind' => 'trmnl',
'published_at' => '2023-01-01T00:00:00Z',
],
],
@@ -74,6 +75,7 @@ test('fetch device models job handles successful api response', function (): voi
expect($deviceModel->mime_type)->toBe('image/png');
expect($deviceModel->offset_x)->toBe(0);
expect($deviceModel->offset_y)->toBe(0);
+ // expect($deviceModel->kind)->toBe('trmnl');
expect($deviceModel->source)->toBe('api');
});
@@ -312,6 +314,7 @@ test('fetch device models job handles device model with partial data', function
expect($deviceModel->mime_type)->toBe('');
expect($deviceModel->offset_x)->toBe(0);
expect($deviceModel->offset_y)->toBe(0);
+ expect($deviceModel->kind)->toBeNull();
expect($deviceModel->source)->toBe('api');
});
diff --git a/tests/Feature/Livewire/Plugins/ConfigModalTest.php b/tests/Feature/Livewire/Plugins/ConfigModalTest.php
new file mode 100644
index 0000000..4372991
--- /dev/null
+++ b/tests/Feature/Livewire/Plugins/ConfigModalTest.php
@@ -0,0 +1,124 @@
+create();
+ $this->actingAs($user);
+
+ $plugin = Plugin::create([
+ 'uuid' => Str::uuid(),
+ 'user_id' => $user->id,
+ 'name' => 'Test Plugin',
+ 'data_strategy' => 'static',
+ 'configuration_template' => [
+ 'custom_fields' => [[
+ 'keyname' => 'tags',
+ 'field_type' => 'multi_string',
+ 'name' => 'Reading Days',
+ 'default' => 'alpha,beta',
+ ]]
+ ],
+ 'configuration' => ['tags' => 'alpha,beta']
+ ]);
+
+ Volt::test('plugins.config-modal', ['plugin' => $plugin])
+ ->assertSet('multiValues.tags', ['alpha', 'beta']);
+});
+
+test('config modal validates against commas in multi_string boxes', function (): void {
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $plugin = Plugin::create([
+ 'uuid' => Str::uuid(),
+ 'user_id' => $user->id,
+ 'name' => 'Test Plugin',
+ 'data_strategy' => 'static',
+ 'configuration_template' => [
+ 'custom_fields' => [[
+ 'keyname' => 'tags',
+ 'field_type' => 'multi_string',
+ 'name' => 'Reading Days',
+ ]]
+ ]
+ ]);
+
+ Volt::test('plugins.config-modal', ['plugin' => $plugin])
+ ->set('multiValues.tags.0', 'no,commas,allowed')
+ ->call('saveConfiguration')
+ ->assertHasErrors(['multiValues.tags.0' => 'regex']);
+
+ // Assert DB remains unchanged
+ expect($plugin->fresh()->configuration['tags'] ?? '')->not->toBe('no,commas,allowed');
+});
+
+test('config modal merges multi_string boxes into a single CSV string on save', function (): void {
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $plugin = Plugin::create([
+ 'uuid' => Str::uuid(),
+ 'user_id' => $user->id,
+ 'name' => 'Test Plugin',
+ 'data_strategy' => 'static',
+ 'configuration_template' => [
+ 'custom_fields' => [[
+ 'keyname' => 'items',
+ 'field_type' => 'multi_string',
+ 'name' => 'Reading Days',
+ ]]
+ ],
+ 'configuration' => []
+ ]);
+
+ Volt::test('plugins.config-modal', ['plugin' => $plugin])
+ ->set('multiValues.items.0', 'First')
+ ->call('addMultiItem', 'items')
+ ->set('multiValues.items.1', 'Second')
+ ->call('saveConfiguration')
+ ->assertHasNoErrors();
+
+ expect($plugin->fresh()->configuration['items'])->toBe('First,Second');
+});
+
+test('config modal resetForm clears dirty state and increments resetIndex', function (): void {
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $plugin = Plugin::create([
+ 'uuid' => Str::uuid(),
+ 'user_id' => $user->id,
+ 'name' => 'Test Plugin',
+ 'data_strategy' => 'static',
+ 'configuration' => ['simple_key' => 'original_value']
+ ]);
+
+ Volt::test('plugins.config-modal', ['plugin' => $plugin])
+ ->set('configuration.simple_key', 'dirty_value')
+ ->call('resetForm')
+ ->assertSet('configuration.simple_key', 'original_value')
+ ->assertSet('resetIndex', 1);
+});
+
+test('config modal dispatches update event for parent warning refresh', function (): void {
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $plugin = Plugin::create([
+ 'uuid' => Str::uuid(),
+ 'user_id' => $user->id,
+ 'name' => 'Test Plugin',
+ 'data_strategy' => 'static'
+ ]);
+
+ Volt::test('plugins.config-modal', ['plugin' => $plugin])
+ ->call('saveConfiguration')
+ ->assertDispatched('config-updated');
+});
diff --git a/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php b/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php
new file mode 100644
index 0000000..a04815f
--- /dev/null
+++ b/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php
@@ -0,0 +1,112 @@
+create();
+ $this->actingAs($user);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'trmnlp_id' => null,
+ ]);
+
+ $trmnlpId = (string) Str::uuid();
+
+ Volt::test('plugins.recipes.settings', ['plugin' => $plugin])
+ ->set('trmnlp_id', $trmnlpId)
+ ->call('saveTrmnlpId')
+ ->assertHasNoErrors();
+
+ expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId);
+});
+
+test('recipe settings validates trmnlp_id is unique per user', function (): void {
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $existingPlugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'trmnlp_id' => 'existing-id-123',
+ ]);
+
+ $newPlugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'trmnlp_id' => null,
+ ]);
+
+ Volt::test('plugins.recipes.settings', ['plugin' => $newPlugin])
+ ->set('trmnlp_id', 'existing-id-123')
+ ->call('saveTrmnlpId')
+ ->assertHasErrors(['trmnlp_id' => 'unique']);
+
+ expect($newPlugin->fresh()->trmnlp_id)->toBeNull();
+});
+
+test('recipe settings allows same trmnlp_id for different users', function (): void {
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+
+ $plugin1 = Plugin::factory()->create([
+ 'user_id' => $user1->id,
+ 'trmnlp_id' => 'shared-id-123',
+ ]);
+
+ $plugin2 = Plugin::factory()->create([
+ 'user_id' => $user2->id,
+ 'trmnlp_id' => null,
+ ]);
+
+ $this->actingAs($user2);
+
+ Volt::test('plugins.recipes.settings', ['plugin' => $plugin2])
+ ->set('trmnlp_id', 'shared-id-123')
+ ->call('saveTrmnlpId')
+ ->assertHasNoErrors();
+
+ expect($plugin2->fresh()->trmnlp_id)->toBe('shared-id-123');
+});
+
+test('recipe settings allows same trmnlp_id for the same plugin', function (): void {
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $trmnlpId = (string) Str::uuid();
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'trmnlp_id' => $trmnlpId,
+ ]);
+
+ Volt::test('plugins.recipes.settings', ['plugin' => $plugin])
+ ->set('trmnlp_id', $trmnlpId)
+ ->call('saveTrmnlpId')
+ ->assertHasNoErrors();
+
+ expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId);
+});
+
+test('recipe settings can clear trmnlp_id', function (): void {
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'trmnlp_id' => 'some-id',
+ ]);
+
+ Volt::test('plugins.recipes.settings', ['plugin' => $plugin])
+ ->set('trmnlp_id', '')
+ ->call('saveTrmnlpId')
+ ->assertHasNoErrors();
+
+ expect($plugin->fresh()->trmnlp_id)->toBeNull();
+});
diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php
index 1b20f93..f3ef1fa 100644
--- a/tests/Feature/PluginImportTest.php
+++ b/tests/Feature/PluginImportTest.php
@@ -83,19 +83,34 @@ it('throws exception for invalid zip file', function (): void {
->toThrow(Exception::class, 'Could not open the ZIP file.');
});
-it('throws exception for missing required files', function (): void {
+it('throws exception for missing settings.yml', function (): void {
$user = User::factory()->create();
$zipContent = createMockZipFile([
- 'src/settings.yml' => getValidSettingsYaml(),
- // Missing full.liquid
+ 'src/full.liquid' => getValidFullLiquid(),
+ // Missing settings.yml
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user))
- ->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid are missing.');
+ ->toThrow(Exception::class, 'Invalid ZIP structure. Required file settings.yml is missing.');
+});
+
+it('throws exception for missing template files', function (): void {
+ $user = User::factory()->create();
+
+ $zipContent = createMockZipFile([
+ 'src/settings.yml' => getValidSettingsYaml(),
+ // Missing all template files
+ ]);
+
+ $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
+
+ $pluginImportService = new PluginImportService();
+ expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user))
+ ->toThrow(Exception::class, 'Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
});
it('sets default values when settings are missing', function (): void {
@@ -427,6 +442,103 @@ YAML;
->and($displayIncidentField['default'])->toBe('true');
});
+it('throws exception when multi_string default value contains a comma', function (): void {
+ $user = User::factory()->create();
+
+ // YAML with a comma in the 'default' field of a multi_string
+ $invalidYaml = <<<'YAML'
+name: Test Plugin
+refresh_interval: 30
+strategy: static
+polling_verb: get
+static_data: '{"test": "data"}'
+custom_fields:
+ - keyname: api_key
+ field_type: multi_string
+ default: default-api-key1,default-api-key2
+ label: API Key
+YAML;
+
+ $zipContent = createMockZipFile([
+ 'src/settings.yml' => $invalidYaml,
+ 'src/full.liquid' => getValidFullLiquid(),
+ ]);
+
+ $zipFile = UploadedFile::fake()->createWithContent('invalid-default.zip', $zipContent);
+ $pluginImportService = new PluginImportService();
+
+ expect(fn () => $pluginImportService->importFromZip($zipFile, $user))
+ ->toThrow(Exception::class, 'Validation Error: The default value for multistring fields like `api_key` cannot contain commas.');
+});
+
+it('throws exception when multi_string placeholder contains a comma', function (): void {
+ $user = User::factory()->create();
+
+ // YAML with a comma in the 'placeholder' field
+ $invalidYaml = <<<'YAML'
+name: Test Plugin
+refresh_interval: 30
+strategy: static
+polling_verb: get
+static_data: '{"test": "data"}'
+custom_fields:
+ - keyname: api_key
+ field_type: multi_string
+ default: default-api-key
+ label: API Key
+ placeholder: "value1, value2"
+YAML;
+
+ $zipContent = createMockZipFile([
+ 'src/settings.yml' => $invalidYaml,
+ 'src/full.liquid' => getValidFullLiquid(),
+ ]);
+
+ $zipFile = UploadedFile::fake()->createWithContent('invalid-placeholder.zip', $zipContent);
+ $pluginImportService = new PluginImportService();
+
+ expect(fn () => $pluginImportService->importFromZip($zipFile, $user))
+ ->toThrow(Exception::class, 'Validation Error: The placeholder value for multistring fields like `api_key` cannot contain commas.');
+});
+
+it('imports plugin with only shared.liquid file', function (): void {
+ $user = User::factory()->create();
+
+ $zipContent = createMockZipFile([
+ 'src/settings.yml' => getValidSettingsYaml(),
+ 'src/shared.liquid' => '{{ data.title }}
',
+ ]);
+
+ $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
+
+ $pluginImportService = new PluginImportService();
+ $plugin = $pluginImportService->importFromZip($zipFile, $user);
+
+ expect($plugin)->toBeInstanceOf(Plugin::class)
+ ->and($plugin->markup_language)->toBe('liquid')
+ ->and($plugin->render_markup)->toContain('')
+ ->and($plugin->render_markup)->toContain('
{{ data.title }}
');
+});
+
+it('imports plugin with only shared.blade.php file', function (): void {
+ $user = User::factory()->create();
+
+ $zipContent = createMockZipFile([
+ 'src/settings.yml' => getValidSettingsYaml(),
+ 'src/shared.blade.php' => '
{{ $data["title"] }}
',
+ ]);
+
+ $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
+
+ $pluginImportService = new PluginImportService();
+ $plugin = $pluginImportService->importFromZip($zipFile, $user);
+
+ expect($plugin)->toBeInstanceOf(Plugin::class)
+ ->and($plugin->markup_language)->toBe('blade')
+ ->and($plugin->render_markup)->toBe('
{{ $data["title"] }}
')
+ ->and($plugin->render_markup)->not->toContain('
');
+});
+
// Helper methods
function createMockZipFile(array $files): string
{
diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php
index cf8ea97..aa9a28e 100644
--- a/tests/Unit/Models/PluginTest.php
+++ b/tests/Unit/Models/PluginTest.php
@@ -99,6 +99,35 @@ test('updateDataPayload handles multiple URLs with IDX_ prefixes', function ():
expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']);
});
+test('updateDataPayload skips empty lines in polling_url and maintains sequential IDX keys', function (): void {
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ // empty lines and extra spaces between the URL to generate empty entries
+ 'polling_url' => "https://api1.example.com/data\n \n\nhttps://api2.example.com/weather\n ",
+ 'polling_verb' => 'get',
+ ]);
+
+ // Mock only the valid URLs
+ Http::fake([
+ 'https://api1.example.com/data' => Http::response(['item' => 'first'], 200),
+ 'https://api2.example.com/weather' => Http::response(['item' => 'second'], 200),
+ ]);
+
+ $plugin->updateDataPayload();
+
+ // payload should only have 2 items, and they should be indexed 0 and 1
+ expect($plugin->data_payload)->toHaveCount(2);
+ expect($plugin->data_payload)->toHaveKey('IDX_0');
+ expect($plugin->data_payload)->toHaveKey('IDX_1');
+
+ // data is correct
+ expect($plugin->data_payload['IDX_0'])->toBe(['item' => 'first']);
+ expect($plugin->data_payload['IDX_1'])->toBe(['item' => 'second']);
+
+ // no empty index exists
+ expect($plugin->data_payload)->not->toHaveKey('IDX_2');
+});
+
test('updateDataPayload handles single URL without nesting', function (): void {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
@@ -679,3 +708,233 @@ test('plugin render includes utc_offset and time_zone_iana in trmnl.user context
->toContain('America/Chicago')
->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds)
});
+
+/**
+ * Plugin security: XSS Payload Dataset
+ * [Input, Expected Result, Forbidden String]
+ */
+dataset('xss_vectors', [
+ 'standard_script' => ['Safe ', 'Safe ', '