diff --git a/.devcontainer/cli/Dockerfile b/.devcontainer/cli/Dockerfile index ab13330..0317097 100644 --- a/.devcontainer/cli/Dockerfile +++ b/.devcontainer/cli/Dockerfile @@ -9,8 +9,7 @@ RUN apk add --no-cache composer # Add Chromium and Image Magick for puppeteer. RUN apk add --no-cache \ imagemagick-dev \ - chromium \ - libzip-dev + chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_DOCKER=1 @@ -20,7 +19,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 zip +RUN docker-php-ext-install imagick # 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 3e658b6..8c585c8 100644 --- a/.devcontainer/fpm/Dockerfile +++ b/.devcontainer/fpm/Dockerfile @@ -14,8 +14,7 @@ RUN apk add --no-cache \ nodejs \ npm \ imagemagick-dev \ - chromium \ - libzip-dev + chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_DOCKER=1 @@ -25,7 +24,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 zip +RUN docker-php-ext-install imagick RUN rm -f /usr/bin/php84 RUN ln -s /usr/local/bin/php /usr/bin/php84 diff --git a/.gitignore b/.gitignore index 0eb46d3..02f3d78 100644 --- a/.gitignore +++ b/.gitignore @@ -29,11 +29,3 @@ yarn-error.log /.junie/guidelines.md /CLAUDE.md /.mcp.json -/.ai -.DS_Store -/boost.json -/.gemini -/GEMINI.md -/.claude -/AGENTS.md -/opencode.json diff --git a/README.md b/README.md index acb0b5c..20bae5d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](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** (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. +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 30k downloads and 130+ stars, it’s the most popular community-driven BYOS. ![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot-dark.png) diff --git a/app/Jobs/FetchDeviceModelsJob.php b/app/Jobs/FetchDeviceModelsJob.php index 475c5c7..cb24d98 100644 --- a/app/Jobs/FetchDeviceModelsJob.php +++ b/app/Jobs/FetchDeviceModelsJob.php @@ -199,7 +199,6 @@ 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/Device.php b/app/Models/Device.php index 3583f48..2eeb25b 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -20,14 +20,6 @@ class Device extends Model protected $guarded = ['id']; - /** - * Set the MAC address attribute, normalizing to uppercase. - */ - public function setMacAddressAttribute(?string $value): void - { - $this->attributes['mac_address'] = $value ? mb_strtoupper($value) : null; - } - protected $casts = [ 'battery_notification_sent' => 'boolean', 'proxy_cloud' => 'boolean', diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index b4daf5e..7b55a73 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -37,32 +37,21 @@ class Playlist extends Model return false; } - // Get user's timezone or fall back to app timezone - $timezone = $this->device->user->timezone ?? config('app.timezone'); - $now = now($timezone); - - // Check weekday (using timezone-aware time) - if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) { + // Check weekday + if ($this->weekdays !== null && ! in_array(now()->dayOfWeek, $this->weekdays)) { return false; } if ($this->active_from !== null && $this->active_until !== null) { - // Create timezone-aware datetime objects for active_from and active_until - $activeFrom = $now->copy() - ->setTimeFrom($this->active_from) - ->timezone($timezone); - - $activeUntil = $now->copy() - ->setTimeFrom($this->active_until) - ->timezone($timezone); + $now = now(); // Handle time ranges that span across midnight - if ($activeFrom > $activeUntil) { + if ($this->active_from > $this->active_until) { // Time range spans midnight (e.g., 09:01 to 03:58) - if ($now >= $activeFrom || $now <= $activeUntil) { + if ($now >= $this->active_from || $now <= $this->active_until) { return true; } - } elseif ($now >= $activeFrom && $now <= $activeUntil) { + } elseif ($now >= $this->active_from && $now <= $this->active_until) { return true; } diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 68f8e7e..2915247 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -24,7 +24,6 @@ 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; @@ -45,8 +44,6 @@ class Plugin extends Model 'no_bleed' => 'boolean', 'dark_mode' => 'boolean', 'preferred_renderer' => 'string', - 'plugin_type' => 'string', - 'alias' => 'boolean', ]; protected static function boot() @@ -58,18 +55,6 @@ class Plugin extends Model $model->uuid = Str::uuid(); } }); - - static::updating(function ($model): void { - // Reset image cache when markup changes - if ($model->isDirty('render_markup')) { - $model->current_image = null; - } - }); - - // Sanitize configuration template on save - static::saving(function ($model): void { - $model->sanitizeTemplate(); - }); } public function user() @@ -77,25 +62,6 @@ 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'])) { @@ -136,11 +102,6 @@ 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()); @@ -154,67 +115,105 @@ class Plugin extends Model public function updateDataPayload(): void { - if ($this->data_strategy !== 'polling' || ! $this->polling_url) { - return; - } - $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; + if ($this->data_strategy === 'polling' && $this->polling_url) { - // 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]); + $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 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) - )); + // 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) + ); - $combinedResponse = []; + // If only one URL, use the original logic without nesting + if (count($urls) === 1) { + $url = reset($urls); + $httpRequest = Http::withHeaders($headers); - // 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; + 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); } - } catch (Exception $e) { - Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage()); - $combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data']; + + // 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(), + ]); } - - // 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 @@ -417,10 +416,6 @@ 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 = ''; @@ -528,30 +523,17 @@ class Plugin extends Model if ($this->render_markup_view) { if ($standalone) { - $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), + 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, + 'slot' => view($this->render_markup_view, [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ])->render(), ])->render(); } @@ -582,61 +564,4 @@ 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 405ea3f..cdfc9d2 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -26,44 +26,11 @@ 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 or Device (for legacy devices) - $imageSettings = $deviceModel - ? self::getImageSettingsFromModel($deviceModel) - : ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null)); + // Get image generation settings from DeviceModel if available, otherwise use device settings + $imageSettings = self::getImageSettings($device); $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png'; $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); @@ -78,7 +45,7 @@ class ImageGenerationService $browserStage->html($markup); // Set timezone from user or fall back to app timezone - $timezone = $user?->timezone ?? config('app.timezone'); + $timezone = $device->user->timezone ?? config('app.timezone'); $browserStage->timezone($timezone); if (config('app.puppeteer_window_size_strategy') === 'v2') { @@ -98,12 +65,12 @@ class ImageGenerationService $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']); } - // Get palette from parameter or fallback to device model's default palette + // Get palette from device or fallback to device model's default palette + $palette = $device->palette ?? $device->deviceModel?->palette; $colorPalette = null; + if ($palette && $palette->colors) { $colorPalette = $palette->colors; - } elseif ($deviceModel?->palette && $deviceModel->palette->colors) { - $colorPalette = $deviceModel->palette->colors; } $imageStage = new ImageStage(); @@ -140,7 +107,8 @@ class ImageGenerationService throw new RuntimeException('Image file is empty: '.$outputPath); } - Log::info("Generated image: $uuid"); + $device->update(['current_screen_image' => $uuid]); + Log::info("Device $device->id: updated with new image: $uuid"); return $uuid; @@ -157,7 +125,22 @@ class ImageGenerationService { // If device has a DeviceModel, use its settings if ($device->deviceModel) { - return self::getImageSettingsFromModel($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, + ]; } // Fallback to device settings @@ -181,43 +164,6 @@ 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 */ @@ -334,10 +280,6 @@ 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 { @@ -369,7 +311,7 @@ class ImageGenerationService public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string { // Validate image type - if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { + if (! in_array($imageType, ['setup-logo', 'sleep'])) { return null; } @@ -403,10 +345,10 @@ class ImageGenerationService /** * Generate a default screen image from Blade template */ - public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string + public static function generateDefaultScreenImage(Device $device, string $imageType): string { // Validate image type - if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { + if (! in_array($imageType, ['setup-logo', 'sleep'])) { throw new InvalidArgumentException("Invalid image type: {$imageType}"); } @@ -423,7 +365,7 @@ class ImageGenerationService $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); // Generate HTML from Blade template - $html = self::generateDefaultScreenHtml($device, $imageType, $pluginName); + $html = self::generateDefaultScreenHtml($device, $imageType); // Create custom Browsershot instance if using AWS Lambda $browsershotInstance = null; @@ -503,13 +445,12 @@ class ImageGenerationService /** * Generate HTML from Blade template for default screens */ - private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string + private static function generateDefaultScreenHtml(Device $device, string $imageType): string { // Map image type to template name $templateName = match ($imageType) { 'setup-logo' => 'default-screens.setup', 'sleep' => 'default-screens.sleep', - 'error' => 'default-screens.error', default => throw new InvalidArgumentException("Invalid image type: {$imageType}") }; @@ -520,22 +461,14 @@ class ImageGenerationService $scaleLevel = $device->scaleLevel(); $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode - // Build view data - $viewData = [ + // Render the Blade template + return view($templateName, [ 'noBleed' => false, 'darkMode' => $darkMode, 'deviceVariant' => $deviceVariant, 'deviceOrientation' => $deviceOrientation, 'colorDepth' => $colorDepth, 'scaleLevel' => $scaleLevel, - ]; - - // Add plugin name for error screens - if ($imageType === 'error' && $pluginName !== null) { - $viewData['pluginName'] = $pluginName; - } - - // Render the Blade template - return view($templateName, $viewData)->render(); + ])->render(); } } diff --git a/app/Services/Plugin/Parsers/IcalResponseParser.php b/app/Services/Plugin/Parsers/IcalResponseParser.php index c8f2b58..f87e71c 100644 --- a/app/Services/Plugin/Parsers/IcalResponseParser.php +++ b/app/Services/Plugin/Parsers/IcalResponseParser.php @@ -34,7 +34,7 @@ class IcalResponseParser implements ResponseParser $filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool { $startDate = $this->asCarbon($event['DTSTART'] ?? null); - if (! $startDate instanceof Carbon) { + if (!$startDate instanceof \Carbon\Carbon) { return false; } diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index 49dce99..9207e3e 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -17,34 +17,6 @@ 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 * @@ -75,55 +47,32 @@ class PluginImportService $zip->extractTo($tempDir); $zip->close(); - // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php) + // Find the required files (settings.yml and full.liquid/full.blade.php) $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); // Validate that we found the required files - 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.'); + if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { + throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php } // Parse settings.yml $settingsYaml = File::get($filePaths['settingsYamlPath']); $settings = Yaml::parse($settingsYaml); - $this->validateYAML($settings); - // Determine which template file to use and read its content - $templatePath = null; + // 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 $markupLanguage = 'blade'; - - 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); + if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') { $markupLanguage = 'liquid'; $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; - } elseif ($filePaths['sharedBladePath']) { - $templatePath = $filePaths['sharedBladePath']; - $fullLiquid = File::get($templatePath); - $markupLanguage = 'blade'; } // Ensure custom_fields is properly formatted @@ -195,12 +144,11 @@ 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, bool $allowDuplicate = false): Plugin + public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin { // Download the ZIP file $response = Http::timeout(60)->get($zipUrl); @@ -228,55 +176,32 @@ class PluginImportService $zip->extractTo($tempDir); $zip->close(); - // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php) + // Find the required files (settings.yml and full.liquid/full.blade.php) $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); // Validate that we found the required files - 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.'); + if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { + throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.'); } // Parse settings.yml $settingsYaml = File::get($filePaths['settingsYamlPath']); $settings = Yaml::parse($settingsYaml); - $this->validateYAML($settings); - // Determine which template file to use and read its content - $templatePath = null; + // 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 $markupLanguage = 'blade'; - - 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); + if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') { $markupLanguage = 'liquid'; $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; - } elseif ($filePaths['sharedBladePath']) { - $templatePath = $filePaths['sharedBladePath']; - $fullLiquid = File::get($templatePath); - $markupLanguage = 'blade'; } // Ensure custom_fields is properly formatted @@ -292,26 +217,17 @@ class PluginImportService 'custom_fields' => $settings['custom_fields'], ]; - // 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_updated = 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' => $trmnlpId, + 'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), ], [ 'user_id' => $user->id, 'name' => $settings['name'] ?? 'Imported Plugin', - 'trmnlp_id' => $trmnlpId, + 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), 'data_stale_minutes' => $settings['refresh_interval'] ?? 15, 'data_strategy' => $settings['strategy'] ?? 'static', 'polling_url' => $settings['polling_url'] ?? null, @@ -356,7 +272,6 @@ class PluginImportService $settingsYamlPath = null; $fullLiquidPath = null; $sharedLiquidPath = null; - $sharedBladePath = null; // If zipEntryPath is specified, look for files in that specific directory first if ($zipEntryPath) { @@ -374,8 +289,6 @@ class PluginImportService if (File::exists($targetDir.'/shared.liquid')) { $sharedLiquidPath = $targetDir.'/shared.liquid'; - } elseif (File::exists($targetDir.'/shared.blade.php')) { - $sharedBladePath = $targetDir.'/shared.blade.php'; } } @@ -391,18 +304,15 @@ 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 || $sharedLiquidPath || $sharedBladePath)) { + if ($settingsYamlPath && $fullLiquidPath) { return [ 'settingsYamlPath' => $settingsYamlPath, 'fullLiquidPath' => $fullLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath, - 'sharedBladePath' => $sharedBladePath, ]; } } @@ -419,11 +329,9 @@ class PluginImportService $fullLiquidPath = $tempDir.'/src/full.blade.php'; } - // Check for shared.liquid or shared.blade.php in the same directory + // Check for shared.liquid 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 @@ -440,24 +348,20 @@ class PluginImportService $fullLiquidPath = $filepath; } elseif ($filename === 'shared.liquid') { $sharedLiquidPath = $filepath; - } elseif ($filename === 'shared.blade.php') { - $sharedBladePath = $filepath; } } - // Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid - if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) { + // Check if shared.liquid exists in the same directory as full.liquid + if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath) { $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 || $sharedLiquidPath || $sharedBladePath)) { + if ($settingsYamlPath && $fullLiquidPath) { // If the files are in the root of the ZIP, create a src folder and move them there $srcDir = dirname((string) $settingsYamlPath); @@ -468,25 +372,17 @@ class PluginImportService // Copy the files to the src directory File::copy($settingsYamlPath, $newSrcDir.'/settings.yml'); + File::copy($fullLiquidPath, $newSrcDir.'/full.liquid'); - // 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 + // Copy shared.liquid 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'; } } } @@ -495,7 +391,6 @@ class PluginImportService 'settingsYamlPath' => $settingsYamlPath, 'fullLiquidPath' => $fullLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath, - 'sharedBladePath' => $sharedBladePath, ]; } diff --git a/boost.json b/boost.json new file mode 100644 index 0000000..53962fa --- /dev/null +++ b/boost.json @@ -0,0 +1,15 @@ +{ + "agents": [ + "claude_code", + "copilot", + "cursor", + "phpstorm" + ], + "editors": [ + "claude_code", + "cursor", + "phpstorm", + "vscode" + ], + "guidelines": [] +} diff --git a/composer.json b/composer.json index 8903e17..2281415 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,6 @@ "keywords": [ "trmnl", "trmnl-server", - "trmnl-byos", "laravel" ], "license": "MIT", @@ -15,7 +14,7 @@ "ext-imagick": "*", "ext-simplexml": "*", "ext-zip": "*", - "bnussbau/laravel-trmnl-blade": "2.1.*", + "bnussbau/laravel-trmnl-blade": "2.0.*", "bnussbau/trmnl-pipeline-php": "^0.6.0", "keepsuit/laravel-liquid": "^0.5.2", "laravel/framework": "^12.1", @@ -25,9 +24,7 @@ "livewire/flux": "^2.0", "livewire/volt": "^1.7", "om/icalparser": "^3.2", - "simplesoftwareio/simple-qrcode": "^4.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 a469e55..9d34443 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": "4de5f1df0160f59d08f428e36e81262e", + "content-hash": "3e4c22c016c04e49512b5fcd20983baa", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.12", + "version": "3.366.1", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "36ee8894743a254ae2650bad4968c874b76bc7de" + "reference": "981ae91529b990987bdb182c11322dd34848976a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/36ee8894743a254ae2650bad4968c874b76bc7de", - "reference": "36ee8894743a254ae2650bad4968c874b76bc7de", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/981ae91529b990987bdb182c11322dd34848976a", + "reference": "981ae91529b990987bdb182c11322dd34848976a", "shasum": "" }, "require": { @@ -85,7 +85,7 @@ "mtdowling/jmespath.php": "^2.8.0", "php": ">=8.1", "psr/http-message": "^1.0 || ^2.0", - "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + "symfony/filesystem": "^v6.4.3 || ^v7.1.0 || ^v8.0.0" }, "require-dev": { "andrewsville/php-token-reflection": "^1.4", @@ -153,76 +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.12" + "source": "https://github.com/aws/aws-sdk-php/tree/3.366.1" }, - "time": "2026-01-13T19:12:08+00:00" - }, - { - "name": "bacon/bacon-qr-code", - "version": "2.0.8", - "source": { - "type": "git", - "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", - "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", - "shasum": "" - }, - "require": { - "dasprid/enum": "^1.0.3", - "ext-iconv": "*", - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "phly/keep-a-changelog": "^2.1", - "phpunit/phpunit": "^7 | ^8 | ^9", - "spatie/phpunit-snapshot-assertions": "^4.2.9", - "squizlabs/php_codesniffer": "^3.4" - }, - "suggest": { - "ext-imagick": "to generate QR code images" - }, - "type": "library", - "autoload": { - "psr-4": { - "BaconQrCode\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Ben Scholzen 'DASPRiD'", - "email": "mail@dasprids.de", - "homepage": "https://dasprids.de/", - "role": "Developer" - } - ], - "description": "BaconQrCode is a QR code generator for PHP.", - "homepage": "https://github.com/Bacon/BaconQrCode", - "support": { - "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" - }, - "time": "2022-12-07T17:46:57+00:00" + "time": "2025-12-04T16:55:00+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", - "version": "2.1.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/bnussbau/laravel-trmnl-blade.git", - "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7" + "reference": "59343cfa9c41c7c7f9285b366584cde92bf1294e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/1e1cabfead00118d7a80c86ac6108aece2989bc7", - "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7", + "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/59343cfa9c41c7c7f9285b366584cde92bf1294e", + "reference": "59343cfa9c41c7c7f9285b366584cde92bf1294e", "shasum": "" }, "require": { @@ -277,7 +223,7 @@ ], "support": { "issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues", - "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.1.0" + "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.0.1" }, "funding": [ { @@ -293,7 +239,7 @@ "type": "github" } ], - "time": "2026-01-02T20:38:51+00:00" + "time": "2025-09-22T12:12:00+00:00" }, { "name": "bnussbau/trmnl-pipeline-php", @@ -495,56 +441,6 @@ ], "time": "2024-02-09T16:56:22+00:00" }, - { - "name": "dasprid/enum", - "version": "1.0.7", - "source": { - "type": "git", - "url": "https://github.com/DASPRiD/Enum.git", - "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", - "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", - "shasum": "" - }, - "require": { - "php": ">=7.1 <9.0" - }, - "require-dev": { - "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", - "squizlabs/php_codesniffer": "*" - }, - "type": "library", - "autoload": { - "psr-4": { - "DASPRiD\\Enum\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Ben Scholzen 'DASPRiD'", - "email": "mail@dasprids.de", - "homepage": "https://dasprids.de/", - "role": "Developer" - } - ], - "description": "PHP 7.1 enum implementation", - "keywords": [ - "enum", - "map" - ], - "support": { - "issues": "https://github.com/DASPRiD/Enum/issues", - "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" - }, - "time": "2025-09-16T12:23:56+00:00" - }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -918,79 +814,18 @@ ], "time": "2025-03-06T22:45:56+00:00" }, - { - "name": "ezyang/htmlpurifier", - "version": "v4.19.0", - "source": { - "type": "git", - "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" - }, - "dist": { - "type": "zip", - "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", + "version": "v6.11.1", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", "shasum": "" }, "require": { @@ -1038,9 +873,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" }, - "time": "2025-12-16T22:17:28+00:00" + "time": "2025-04-09T20:32:01+00:00" }, { "name": "fruitcake/php-cors", @@ -1115,24 +950,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.4", + "version": "v1.1.3", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "phpoption/phpoption": "^1.9.3" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" }, "type": "library", "autoload": { @@ -1161,7 +996,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" }, "funding": [ { @@ -1173,7 +1008,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2024-07-20T21:45:45+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1782,16 +1617,16 @@ }, { "name": "laravel/framework", - "version": "v12.47.0", + "version": "v12.41.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "ab8114c2e78f32e64eb238fc4b495bea3f8b80ec" + "reference": "3e229b05935fd0300c632fb1f718c73046d664fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/ab8114c2e78f32e64eb238fc4b495bea3f8b80ec", - "reference": "ab8114c2e78f32e64eb238fc4b495bea3f8b80ec", + "url": "https://api.github.com/repos/laravel/framework/zipball/3e229b05935fd0300c632fb1f718c73046d664fc", + "reference": "3e229b05935fd0300c632fb1f718c73046d664fc", "shasum": "" }, "require": { @@ -1879,7 +1714,6 @@ "illuminate/process": "self.version", "illuminate/queue": "self.version", "illuminate/redis": "self.version", - "illuminate/reflection": "self.version", "illuminate/routing": "self.version", "illuminate/session": "self.version", "illuminate/support": "self.version", @@ -1904,7 +1738,7 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.8.1", + "orchestra/testbench-core": "^10.8.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -1966,7 +1800,6 @@ "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", "src/Illuminate/Log/functions.php", - "src/Illuminate/Reflection/helpers.php", "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], @@ -1975,8 +1808,7 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/", - "src/Illuminate/Reflection/" + "src/Illuminate/Conditionable/" ] } }, @@ -2000,20 +1832,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-13T15:29:06+00:00" + "time": "2025-12-03T01:02:13+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.9", + "version": "v0.3.8", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "5c41bf0555b7cfefaad4e66d3046675829581ac4" + "reference": "096748cdfb81988f60090bbb839ce3205ace0d35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/5c41bf0555b7cfefaad4e66d3046675829581ac4", - "reference": "5c41bf0555b7cfefaad4e66d3046675829581ac4", + "url": "https://api.github.com/repos/laravel/prompts/zipball/096748cdfb81988f60090bbb839ce3205ace0d35", + "reference": "096748cdfb81988f60090bbb839ce3205ace0d35", "shasum": "" }, "require": { @@ -2057,22 +1889,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.9" + "source": "https://github.com/laravel/prompts/tree/v0.3.8" }, - "time": "2026-01-07T21:00:29+00:00" + "time": "2025-11-21T20:52:52+00:00" }, { "name": "laravel/sanctum", - "version": "v4.2.3", + "version": "v4.2.1", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "47d26f1d310879ff757b971f5a6fc631d18663fd" + "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/47d26f1d310879ff757b971f5a6fc631d18663fd", - "reference": "47d26f1d310879ff757b971f5a6fc631d18663fd", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/f5fb373be39a246c74a060f2cf2ae2c2145b3664", + "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664", "shasum": "" }, "require": { @@ -2122,20 +1954,20 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-01-11T18:20:25+00:00" + "time": "2025-11-21T13:59:03+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.8", + "version": "v2.0.7", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", "shasum": "" }, "require": { @@ -2183,25 +2015,25 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-01-08T16:22:46+00:00" + "time": "2025-11-21T20:52:36+00:00" }, { "name": "laravel/socialite", - "version": "v5.24.2", + "version": "v5.23.2", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613" + "reference": "41e65d53762d33d617bf0253330d672cb95e624b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", + "url": "https://api.github.com/repos/laravel/socialite/zipball/41e65d53762d33d617bf0253330d672cb95e624b", + "reference": "41e65d53762d33d617bf0253330d672cb95e624b", "shasum": "" }, "require": { "ext-json": "*", - "firebase/php-jwt": "^6.4|^7.0", + "firebase/php-jwt": "^6.4", "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", @@ -2255,20 +2087,20 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-01-10T16:07:28+00:00" + "time": "2025-11-21T14:00:38+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.0", + "version": "v2.10.2", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" + "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", + "url": "https://api.github.com/repos/laravel/tinker/zipball/3bcb5f62d6f837e0f093a601e26badafb127bd4c", + "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c", "shasum": "" }, "require": { @@ -2277,7 +2109,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|^8.0" + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", @@ -2319,9 +2151,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.0" + "source": "https://github.com/laravel/tinker/tree/v2.10.2" }, - "time": "2025-12-19T19:16:45+00:00" + "time": "2025-11-20T16:29:12+00:00" }, { "name": "league/commonmark", @@ -2834,20 +2666,20 @@ }, { "name": "league/uri", - "version": "7.7.0", + "version": "7.6.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" + "reference": "f625804987a0a9112d954f9209d91fec52182344" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/f625804987a0a9112d954f9209d91fec52182344", + "reference": "f625804987a0a9112d954f9209d91fec52182344", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.7", + "league/uri-interfaces": "^7.6", "php": "^8.1", "psr/http-factory": "^1" }, @@ -2920,7 +2752,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.7.0" + "source": "https://github.com/thephpleague/uri/tree/7.6.0" }, "funding": [ { @@ -2928,20 +2760,20 @@ "type": "github" } ], - "time": "2025-12-07T16:02:06+00:00" + "time": "2025-11-18T12:17:23+00:00" }, { "name": "league/uri-interfaces", - "version": "7.7.0", + "version": "7.6.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" + "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ccbfb51c0445298e7e0b7f4481b942f589665368", + "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368", "shasum": "" }, "require": { @@ -3004,7 +2836,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.6.0" }, "funding": [ { @@ -3012,20 +2844,20 @@ "type": "github" } ], - "time": "2025-12-07T16:03:21+00:00" + "time": "2025-11-18T12:17:23+00:00" }, { "name": "livewire/flux", - "version": "v2.10.2", + "version": "v2.9.2", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975" + "reference": "6572847f70a18e7cf136bb31201d4064f5c8ade1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/e7a93989788429bb6c0a908a056d22ea3a6c7975", - "reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975", + "url": "https://api.github.com/repos/livewire/flux/zipball/6572847f70a18e7cf136bb31201d4064f5c8ade1", + "reference": "6572847f70a18e7cf136bb31201d4064f5c8ade1", "shasum": "" }, "require": { @@ -3033,12 +2865,12 @@ "illuminate/support": "^10.0|^11.0|^12.0", "illuminate/view": "^10.0|^11.0|^12.0", "laravel/prompts": "^0.1|^0.2|^0.3", - "livewire/livewire": "^3.7.3|^4.0", + "livewire/livewire": "^3.5.19|^4.0", "php": "^8.1", "symfony/console": "^6.0|^7.0" }, "conflict": { - "livewire/blaze": "<1.0.0" + "livewire/blaze": "<0.1.0" }, "type": "library", "extra": { @@ -3076,22 +2908,22 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.10.2" + "source": "https://github.com/livewire/flux/tree/v2.9.2" }, - "time": "2025-12-19T02:11:45+00:00" + "time": "2025-12-04T17:09:39+00:00" }, { "name": "livewire/livewire", - "version": "v3.7.3", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "a5384df9fbd3eaf02e053bc49aabc8ace293fc1c" + "reference": "214da8f3a1199a88b56ab2fe901d4a607f784805" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/a5384df9fbd3eaf02e053bc49aabc8ace293fc1c", - "reference": "a5384df9fbd3eaf02e053bc49aabc8ace293fc1c", + "url": "https://api.github.com/repos/livewire/livewire/zipball/214da8f3a1199a88b56ab2fe901d4a607f784805", + "reference": "214da8f3a1199a88b56ab2fe901d4a607f784805", "shasum": "" }, "require": { @@ -3146,7 +2978,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.3" + "source": "https://github.com/livewire/livewire/tree/v3.7.1" }, "funding": [ { @@ -3154,7 +2986,7 @@ "type": "github" } ], - "time": "2025-12-19T02:00:29+00:00" + "time": "2025-12-03T22:41:13+00:00" }, { "name": "livewire/volt", @@ -3229,16 +3061,16 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.2.1", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416", "shasum": "" }, "require": { @@ -3249,7 +3081,7 @@ "require-dev": { "brianium/paratest": "^7.7", "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.86", + "friendsofphp/php-cs-fixer": "^3.16", "guzzlehttp/guzzle": "^7.5", "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.5", @@ -3295,7 +3127,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0" }, "funding": [ { @@ -3303,20 +3135,20 @@ "type": "github" } ], - "time": "2025-12-10T09:58:31+00:00" + "time": "2025-07-17T11:15:13+00:00" }, { "name": "monolog/monolog", - "version": "3.10.0", + "version": "3.9.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", "shasum": "" }, "require": { @@ -3334,7 +3166,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8 || ^2.0", + "mongodb/mongodb": "^1.8", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -3394,7 +3226,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" }, "funding": [ { @@ -3406,7 +3238,7 @@ "type": "tidelift" } ], - "time": "2026-01-02T08:56:05+00:00" + "time": "2025-03-24T10:02:05+00:00" }, { "name": "mtdowling/jmespath.php", @@ -3646,16 +3478,16 @@ }, { "name": "nette/utils", - "version": "v4.1.1", + "version": "v4.1.0", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" + "reference": "fa1f0b8261ed150447979eb22e373b7b7ad5a8e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", + "url": "https://api.github.com/repos/nette/utils/zipball/fa1f0b8261ed150447979eb22e373b7b7ad5a8e0", + "reference": "fa1f0b8261ed150447979eb22e373b7b7ad5a8e0", "shasum": "" }, "require": { @@ -3729,22 +3561,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.1" + "source": "https://github.com/nette/utils/tree/v4.1.0" }, - "time": "2025-12-22T12:14:32+00:00" + "time": "2025-12-01T17:49:23+00:00" }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "3a454ca033b9e06b63282ce19562e892747449bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb", "shasum": "" }, "require": { @@ -3787,9 +3619,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2025-10-21T19:32:17+00:00" }, { "name": "nunomaduro/termwind", @@ -3880,23 +3712,23 @@ }, { "name": "om/icalparser", - "version": "v3.2.1", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/OzzyCzech/icalparser.git", - "reference": "bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc" + "reference": "3aa0716aa9e729f08fba20390773d6dcd685169b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/OzzyCzech/icalparser/zipball/bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc", - "reference": "bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc", + "url": "https://api.github.com/repos/OzzyCzech/icalparser/zipball/3aa0716aa9e729f08fba20390773d6dcd685169b", + "reference": "3aa0716aa9e729f08fba20390773d6dcd685169b", "shasum": "" }, "require": { "php": ">=8.1.0" }, "require-dev": { - "nette/tester": "^2.5.7" + "nette/tester": "^2.5.6" }, "suggest": { "ext-dom": "for timezone tool" @@ -3925,9 +3757,9 @@ ], "support": { "issues": "https://github.com/OzzyCzech/icalparser/issues", - "source": "https://github.com/OzzyCzech/icalparser/tree/v3.2.1" + "source": "https://github.com/OzzyCzech/icalparser/tree/v3.2.0" }, - "time": "2025-12-15T06:25:09+00:00" + "time": "2025-09-08T07:04:53+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -4050,16 +3882,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.5", + "version": "1.9.4", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", "shasum": "" }, "require": { @@ -4109,7 +3941,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" }, "funding": [ { @@ -4121,20 +3953,20 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2025-08-21T11:53:16+00:00" }, { "name": "phpseclib/phpseclib", - "version": "3.0.48", + "version": "3.0.47", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" + "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", - "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/9d6ca36a6c2dd434765b1071b2644a1c683b385d", + "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d", "shasum": "" }, "require": { @@ -4215,7 +4047,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.47" }, "funding": [ { @@ -4231,7 +4063,7 @@ "type": "tidelift" } ], - "time": "2025-12-15T11:51:42+00:00" + "time": "2025-10-06T01:07:24+00:00" }, { "name": "psr/clock", @@ -4647,16 +4479,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.18", + "version": "v0.12.15", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" + "reference": "38953bc71491c838fcb6ebcbdc41ab7483cd549c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/38953bc71491c838fcb6ebcbdc41ab7483cd549c", + "reference": "38953bc71491c838fcb6ebcbdc41ab7483cd549c", "shasum": "" }, "require": { @@ -4664,8 +4496,8 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.4", - "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" @@ -4720,9 +4552,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.15" }, - "time": "2025-12-17T14:35:46+00:00" + "time": "2025-11-28T00:00:14+00:00" }, { "name": "ralouphie/getallheaders", @@ -4846,20 +4678,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.2", + "version": "4.9.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4918,90 +4750,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.1" }, - "time": "2025-12-14T04:43:48+00:00" - }, - { - "name": "simplesoftwareio/simple-qrcode", - "version": "4.2.0", - "source": { - "type": "git", - "url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git", - "reference": "916db7948ca6772d54bb617259c768c9cdc8d537" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537", - "reference": "916db7948ca6772d54bb617259c768c9cdc8d537", - "shasum": "" - }, - "require": { - "bacon/bacon-qr-code": "^2.0", - "ext-gd": "*", - "php": ">=7.2|^8.0" - }, - "require-dev": { - "mockery/mockery": "~1", - "phpunit/phpunit": "~9" - }, - "suggest": { - "ext-imagick": "Allows the generation of PNG QrCodes.", - "illuminate/support": "Allows for use within Laravel." - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode" - }, - "providers": [ - "SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "SimpleSoftwareIO\\QrCode\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Simple Software LLC", - "email": "support@simplesoftware.io" - } - ], - "description": "Simple QrCode is a QR code generator made for Laravel.", - "homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode", - "keywords": [ - "Simple", - "generator", - "laravel", - "qrcode", - "wrapper" - ], - "support": { - "issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues", - "source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0" - }, - "time": "2021-02-08T20:43:55+00:00" + "time": "2025-09-04T20:59:21+00:00" }, { "name": "spatie/browsershot", - "version": "5.2.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/spatie/browsershot.git", - "reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8" + "reference": "127c20da43d0d711ebbc64f85053f50bc147c515" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/browsershot/zipball/9bc6b8d67175810d7a399b2588c3401efe2d02a8", - "reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8", + "url": "https://api.github.com/repos/spatie/browsershot/zipball/127c20da43d0d711ebbc64f85053f50bc147c515", + "reference": "127c20da43d0d711ebbc64f85053f50bc147c515", "shasum": "" }, "require": { @@ -5048,7 +4812,7 @@ "webpage" ], "support": { - "source": "https://github.com/spatie/browsershot/tree/5.2.0" + "source": "https://github.com/spatie/browsershot/tree/5.1.1" }, "funding": [ { @@ -5056,7 +4820,7 @@ "type": "github" } ], - "time": "2025-12-22T10:02:16+00:00" + "time": "2025-11-26T09:49:20+00:00" }, { "name": "spatie/laravel-package-tools", @@ -5121,16 +4885,16 @@ }, { "name": "spatie/temporary-directory", - "version": "2.3.1", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/spatie/temporary-directory.git", - "reference": "662e481d6ec07ef29fd05010433428851a42cd07" + "reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/662e481d6ec07ef29fd05010433428851a42cd07", - "reference": "662e481d6ec07ef29fd05010433428851a42cd07", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/580eddfe9a0a41a902cac6eeb8f066b42e65a32b", + "reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b", "shasum": "" }, "require": { @@ -5166,7 +4930,7 @@ ], "support": { "issues": "https://github.com/spatie/temporary-directory/issues", - "source": "https://github.com/spatie/temporary-directory/tree/2.3.1" + "source": "https://github.com/spatie/temporary-directory/tree/2.3.0" }, "funding": [ { @@ -5178,73 +4942,7 @@ "type": "github" } ], - "time": "2026-01-12T07:42:22+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" + "time": "2025-01-13T13:04:43+00:00" }, { "name": "symfony/clock", @@ -5325,16 +5023,16 @@ }, { "name": "symfony/console", - "version": "v7.4.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" + "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "url": "https://api.github.com/repos/symfony/console/zipball/0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8", + "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8", "shasum": "" }, "require": { @@ -5399,7 +5097,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.3" + "source": "https://github.com/symfony/console/tree/v7.4.0" }, "funding": [ { @@ -5419,24 +5117,24 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -5468,7 +5166,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + "source": "https://github.com/symfony/css-selector/tree/v7.4.0" }, "funding": [ { @@ -5488,7 +5186,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2025-10-30T13:39:42+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5802,16 +5500,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.1", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + "reference": "7fc96ae83372620eaba3826874f46e26295768ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7fc96ae83372620eaba3826874f46e26295768ca", + "reference": "7fc96ae83372620eaba3826874f46e26295768ca", "shasum": "" }, "require": { @@ -5848,7 +5546,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + "source": "https://github.com/symfony/filesystem/tree/v8.0.0" }, "funding": [ { @@ -5868,20 +5566,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2025-11-05T14:36:47+00:00" }, { "name": "symfony/finder", - "version": "v7.4.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fffe05569336549b20a1be64250b40516d6e8d06" + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", - "reference": "fffe05569336549b20a1be64250b40516d6e8d06", + "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", "shasum": "" }, "require": { @@ -5916,7 +5614,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.3" + "source": "https://github.com/symfony/finder/tree/v7.4.0" }, "funding": [ { @@ -5936,20 +5634,20 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2025-11-05T05:42:40+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" + "reference": "769c1720b68e964b13b58529c17d4a385c62167b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", - "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/769c1720b68e964b13b58529c17d4a385c62167b", + "reference": "769c1720b68e964b13b58529c17d4a385c62167b", "shasum": "" }, "require": { @@ -5998,7 +5696,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.3" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.0" }, "funding": [ { @@ -6018,20 +5716,20 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:23:49+00:00" + "time": "2025-11-13T08:49:24+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "885211d4bed3f857b8c964011923528a55702aa5" + "reference": "7348193cd384495a755554382e4526f27c456085" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", - "reference": "885211d4bed3f857b8c964011923528a55702aa5", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7348193cd384495a755554382e4526f27c456085", + "reference": "7348193cd384495a755554382e4526f27c456085", "shasum": "" }, "require": { @@ -6117,7 +5815,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.3" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.0" }, "funding": [ { @@ -6137,20 +5835,20 @@ "type": "tidelift" } ], - "time": "2025-12-31T08:43:57+00:00" + "time": "2025-11-27T13:38:24+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" + "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", - "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", "shasum": "" }, "require": { @@ -6201,7 +5899,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.3" + "source": "https://github.com/symfony/mailer/tree/v7.4.0" }, "funding": [ { @@ -6221,7 +5919,7 @@ "type": "tidelift" } ], - "time": "2025-12-16T08:02:06+00:00" + "time": "2025-11-21T15:26:00+00:00" }, { "name": "symfony/mime", @@ -7143,16 +6841,16 @@ }, { "name": "symfony/process", - "version": "v7.4.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" + "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", "shasum": "" }, "require": { @@ -7184,7 +6882,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.3" + "source": "https://github.com/symfony/process/tree/v7.4.0" }, "funding": [ { @@ -7204,20 +6902,20 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2025-10-16T11:21:06+00:00" }, { "name": "symfony/routing", - "version": "v7.4.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" + "reference": "4720254cb2644a0b876233d258a32bf017330db7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", - "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "url": "https://api.github.com/repos/symfony/routing/zipball/4720254cb2644a0b876233d258a32bf017330db7", + "reference": "4720254cb2644a0b876233d258a32bf017330db7", "shasum": "" }, "require": { @@ -7269,7 +6967,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.3" + "source": "https://github.com/symfony/routing/tree/v7.4.0" }, "funding": [ { @@ -7289,7 +6987,7 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/service-contracts", @@ -7380,16 +7078,16 @@ }, { "name": "symfony/string", - "version": "v8.0.1", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" + "reference": "f929eccf09531078c243df72398560e32fa4cf4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "url": "https://api.github.com/repos/symfony/string/zipball/f929eccf09531078c243df72398560e32fa4cf4f", + "reference": "f929eccf09531078c243df72398560e32fa4cf4f", "shasum": "" }, "require": { @@ -7446,7 +7144,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.1" + "source": "https://github.com/symfony/string/tree/v8.0.0" }, "funding": [ { @@ -7466,20 +7164,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2025-09-11T14:37:55+00:00" }, { "name": "symfony/translation", - "version": "v8.0.3", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d" + "reference": "82ab368a6fca6358d995b6dd5c41590fb42c03e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/60a8f11f0e15c48f2cc47c4da53873bb5b62135d", - "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d", + "url": "https://api.github.com/repos/symfony/translation/zipball/82ab368a6fca6358d995b6dd5c41590fb42c03e6", + "reference": "82ab368a6fca6358d995b6dd5c41590fb42c03e6", "shasum": "" }, "require": { @@ -7539,7 +7237,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.3" + "source": "https://github.com/symfony/translation/tree/v8.0.0" }, "funding": [ { @@ -7559,7 +7257,7 @@ "type": "tidelift" } ], - "time": "2025-12-21T10:59:45+00:00" + "time": "2025-11-27T08:09:45+00:00" }, { "name": "symfony/translation-contracts", @@ -7723,16 +7421,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.3", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92" + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", "shasum": "" }, "require": { @@ -7786,7 +7484,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" }, "funding": [ { @@ -7806,7 +7504,7 @@ "type": "tidelift" } ], - "time": "2025-12-18T07:04:31+00:00" + "time": "2025-10-27T20:36:44+00:00" }, { "name": "symfony/var-exporter", @@ -7891,16 +7589,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.1", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "url": "https://api.github.com/repos/symfony/yaml/zipball/6c84a4b55aee4cd02034d1c528e83f69ddf63810", + "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810", "shasum": "" }, "require": { @@ -7943,7 +7641,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.1" + "source": "https://github.com/symfony/yaml/tree/v7.4.0" }, "funding": [ { @@ -7963,27 +7661,27 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:11:45+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.4.0", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + "reference": "0d72ac1c00084279c1816675284073c5a337c20d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", - "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -8016,32 +7714,32 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" }, - "time": "2025-12-02T11:56:42+00:00" + "time": "2024-12-21T16:25:41+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", + "graham-campbell/result-type": "^1.1.3", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", - "symfony/polyfill-ctype": "^1.26", - "symfony/polyfill-mbstring": "^1.26", - "symfony/polyfill-php80": "^1.26" + "phpoption/phpoption": "^1.9.3", + "symfony/polyfill-ctype": "^1.24", + "symfony/polyfill-mbstring": "^1.24", + "symfony/polyfill-php80": "^1.24" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -8090,7 +7788,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" }, "funding": [ { @@ -8102,7 +7800,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2025-04-30T23:37:27+00:00" }, { "name": "voku/portable-ascii", @@ -8268,16 +7966,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.16.1", + "version": "v7.15.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" + "reference": "272ff9d59b2ed0bd97c86c3cfe97c9784dabf786" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/272ff9d59b2ed0bd97c86c3cfe97c9784dabf786", + "reference": "272ff9d59b2ed0bd97c86c3cfe97c9784dabf786", "shasum": "" }, "require": { @@ -8288,10 +7986,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.2", + "phpunit/php-code-coverage": "^12.5.0", "phpunit/php-file-iterator": "^6", "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.4", + "phpunit/phpunit": "^12.4.4", "sebastian/environment": "^8.0.3", "symfony/console": "^7.3.4 || ^8.0.0", "symfony/process": "^7.3.4 || ^8.0.0" @@ -8301,9 +7999,9 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan": "^2.1.32", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.11", + "phpstan/phpstan-phpunit": "^2.0.8", "phpstan/phpstan-strict-rules": "^2.0.7", "symfony/filesystem": "^7.3.2 || ^8.0.0" }, @@ -8345,7 +8043,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.15.0" }, "funding": [ { @@ -8357,7 +8055,7 @@ "type": "paypal" } ], - "time": "2026-01-08T07:23:06+00:00" + "time": "2025-11-30T08:08:11+00:00" }, { "name": "doctrine/deprecations", @@ -8756,16 +8454,16 @@ }, { "name": "larastan/larastan", - "version": "v3.8.1", + "version": "v3.8.0", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9" + "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/ff3725291bc4c7e6032b5a54776e3e5104c86db9", - "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9", + "url": "https://api.github.com/repos/larastan/larastan/zipball/d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", + "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", "shasum": "" }, "require": { @@ -8779,7 +8477,7 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1", "illuminate/support": "^11.44.2 || ^12.4.1", "php": "^8.2", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.1.29" }, "require-dev": { "doctrine/coding-standard": "^13", @@ -8834,7 +8532,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.8.1" + "source": "https://github.com/larastan/larastan/tree/v3.8.0" }, "funding": [ { @@ -8842,38 +8540,38 @@ "type": "github" } ], - "time": "2025-12-11T16:37:35+00:00" + "time": "2025-10-27T23:09:14+00:00" }, { "name": "laravel/boost", - "version": "v1.8.9", + "version": "v1.8.4", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd" + "reference": "dbdef07edbf101049f6d308654ead2f4324de703" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/1f2c2d41b5216618170fb6730ec13bf894c5bffd", - "reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd", + "url": "https://api.github.com/repos/laravel/boost/zipball/dbdef07edbf101049f6d308654ead2f4324de703", + "reference": "dbdef07edbf101049f6d308654ead2f4324de703", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", - "laravel/mcp": "^0.5.1", + "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", + "laravel/mcp": "^0.4.1", "laravel/prompts": "0.1.25|^0.3.6", "laravel/roster": "^0.2.9", "php": "^8.1" }, "require-dev": { - "laravel/pint": "^1.20.0", + "laravel/pint": "1.20", "mockery/mockery": "^1.6.12", "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", - "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "pestphp/pest": "^2.36.0|^3.8.4", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.1" }, @@ -8908,33 +8606,33 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-01-07T18:43:11+00:00" + "time": "2025-12-05T05:54:57+00:00" }, { "name": "laravel/mcp", - "version": "v0.5.2", + "version": "v0.4.1", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c" + "reference": "27ab10181d25067de7ace427edb562084d0d0aa3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/b9bdd8d6f8b547c8733fe6826b1819341597ba3c", - "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c", + "url": "https://api.github.com/repos/laravel/mcp/zipball/27ab10181d25067de7ace427edb562084d0d0aa3", + "reference": "27ab10181d25067de7ace427edb562084d0d0aa3", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/container": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/http": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/json-schema": "^12.41.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/validation": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/container": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/http": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/json-schema": "^12.28.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/validation": "^10.49.0|^11.45.3|^12.28.1", "php": "^8.1" }, "require-dev": { @@ -8981,7 +8679,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-12-19T19:32:34+00:00" + "time": "2025-12-04T17:29:08+00:00" }, { "name": "laravel/pail", @@ -9064,16 +8762,16 @@ }, { "name": "laravel/pint", - "version": "v1.27.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", "shasum": "" }, "require": { @@ -9084,9 +8782,9 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.4", - "illuminate/view": "^12.44.0", - "larastan/larastan": "^3.8.1", + "friendsofphp/php-cs-fixer": "^3.90.0", + "illuminate/view": "^12.40.1", + "larastan/larastan": "^3.8.0", "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", @@ -9127,7 +8825,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-01-05T16:49:17+00:00" + "time": "2025-11-25T21:15:52+00:00" }, { "name": "laravel/roster", @@ -9192,16 +8890,16 @@ }, { "name": "laravel/sail", - "version": "v1.52.0", + "version": "v1.50.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" + "reference": "9177d5de1c8247166b92ea6049c2b069d2a1802f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "url": "https://api.github.com/repos/laravel/sail/zipball/9177d5de1c8247166b92ea6049c2b069d2a1802f", + "reference": "9177d5de1c8247166b92ea6049c2b069d2a1802f", "shasum": "" }, "require": { @@ -9251,7 +8949,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-01-01T02:46:03+00:00" + "time": "2025-12-03T17:16:36+00:00" }, { "name": "mockery/mockery", @@ -9497,33 +9195,33 @@ }, { "name": "pestphp/pest", - "version": "v4.3.1", + "version": "v4.1.6", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96" + "reference": "ae419afd363299c29ad5b17e8b70d118b1068bb4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/bc57a84e77afd4544ff9643a6858f68d05aeab96", - "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96", + "url": "https://api.github.com/repos/pestphp/pest/zipball/ae419afd363299c29ad5b17e8b70d118b1068bb4", + "reference": "ae419afd363299c29ad5b17e8b70d118b1068bb4", "shasum": "" }, "require": { - "brianium/paratest": "^7.16.0", + "brianium/paratest": "^7.14.2", "nunomaduro/collision": "^8.8.3", "nunomaduro/termwind": "^2.3.3", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", - "pestphp/pest-plugin-profanity": "^4.2.1", + "pestphp/pest-plugin-profanity": "^4.2.0", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.4", - "symfony/process": "^7.4.3|^8.0.0" + "phpunit/phpunit": "^12.4.4", + "symfony/process": "^7.4.0|^8.0.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.4", + "phpunit/phpunit": ">12.4.4", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -9531,7 +9229,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.18" + "psy/psysh": "^0.12.15" }, "bin": [ "bin/pest" @@ -9597,7 +9295,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.3.1" + "source": "https://github.com/pestphp/pest/tree/v4.1.6" }, "funding": [ { @@ -9609,7 +9307,7 @@ "type": "github" } ], - "time": "2026-01-04T16:29:59+00:00" + "time": "2025-11-28T12:04:48+00:00" }, { "name": "pestphp/pest-plugin", @@ -9972,16 +9670,16 @@ }, { "name": "pestphp/pest-plugin-profanity", - "version": "v4.2.1", + "version": "v4.2.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-profanity.git", - "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27" + "reference": "c37e5e2c7136ee4eae12082e7952332bc1c6600a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27", - "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/c37e5e2c7136ee4eae12082e7952332bc1c6600a", + "reference": "c37e5e2c7136ee4eae12082e7952332bc1c6600a", "shasum": "" }, "require": { @@ -10022,9 +9720,9 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.0" }, - "time": "2025-12-08T00:13:17+00:00" + "time": "2025-10-28T23:14:11+00:00" }, { "name": "phar-io/manifest", @@ -10199,16 +9897,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "5.6.5", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/90614c73d3800e187615e2dd236ad0e2a01bf761", + "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761", "shasum": "" }, "require": { @@ -10218,7 +9916,7 @@ "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1 || ^2" + "webmozart/assert": "^1.9.1" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -10257,9 +9955,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.5" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2025-11-27T19:50:05+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -10321,16 +10019,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.1", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374" + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/16dbf9937da8d4528ceb2145c9c7c0bd29e26374", - "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", "shasum": "" }, "require": { @@ -10362,9 +10060,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.1" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" }, - "time": "2026-01-12T11:33:04+00:00" + "time": "2025-08-30T15:50:23+00:00" }, { "name": "phpstan/phpstan", @@ -10421,23 +10119,23 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "bca180c050dd3ae15f87c26d25cabb34fe1a0a5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/bca180c050dd3ae15f87c26d25cabb34fe1a0a5a", + "reference": "bca180c050dd3ae15f87c26d25cabb34fe1a0a5a", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", + "nikic/php-parser": "^5.6.2", "php": ">=8.3", "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", @@ -10445,10 +10143,10 @@ "sebastian/environment": "^8.0.3", "sebastian/lines-of-code": "^4.0", "sebastian/version": "^6.0", - "theseer/tokenizer": "^2.0.1" + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^12.4.4" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -10486,7 +10184,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.0" }, "funding": [ { @@ -10506,7 +10204,7 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2025-11-29T07:15:54+00:00" }, { "name": "phpunit/php-file-iterator", @@ -10755,16 +10453,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.4", + "version": "12.4.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a" + "reference": "9253ec75a672e39fcc9d85bdb61448215b8162c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ba0e923f9d3fc655de22f9547c01d15a41fc93a", - "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9253ec75a672e39fcc9d85bdb61448215b8162c7", + "reference": "9253ec75a672e39fcc9d85bdb61448215b8162c7", "shasum": "" }, "require": { @@ -10778,7 +10476,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.1", + "phpunit/php-code-coverage": "^12.4.0", "phpunit/php-file-iterator": "^6.0.0", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", @@ -10800,7 +10498,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5-dev" + "dev-main": "12.4-dev" } }, "autoload": { @@ -10832,7 +10530,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.4" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.4" }, "funding": [ { @@ -10856,25 +10554,25 @@ "type": "tidelift" } ], - "time": "2025-12-15T06:05:34+00:00" + "time": "2025-11-21T07:39:11+00:00" }, { "name": "rector/rector", - "version": "2.3.1", + "version": "2.2.11", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "9afc1bb43571b25629f353c61a9315b5ef31383a" + "reference": "7bd21a40b0332b93d4bfee284093d7400696902d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9afc1bb43571b25629f353c61a9315b5ef31383a", - "reference": "9afc1bb43571b25629f353c61a9315b5ef31383a", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/7bd21a40b0332b93d4bfee284093d7400696902d", + "reference": "7bd21a40b0332b93d4bfee284093d7400696902d", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.33" + "phpstan/phpstan": "^2.1.32" }, "conflict": { "rector/rector-doctrine": "*", @@ -10908,7 +10606,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.1" + "source": "https://github.com/rectorphp/rector/tree/2.2.11" }, "funding": [ { @@ -10916,7 +10614,7 @@ "type": "github" } ], - "time": "2026-01-13T15:13:58+00:00" + "time": "2025-12-02T11:23:46+00:00" }, { "name": "sebastian/cli-parser", @@ -11928,23 +11626,23 @@ }, { "name": "theseer/tokenizer", - "version": "2.0.1", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", - "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^8.1" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { @@ -11966,7 +11664,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -11974,27 +11672,27 @@ "type": "github" } ], - "time": "2025-12-08T11:19:18+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { "name": "webmozart/assert", - "version": "2.1.2", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^8.2" + "php": "^7.2 || ^8.0" }, "suggest": { "ext-intl": "", @@ -12004,7 +11702,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-feature/2-0": "2.0-dev" + "dev-master": "1.10-dev" } }, "autoload": { @@ -12020,10 +11718,6 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -12034,9 +11728,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" + "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2026-01-13T14:02:24+00:00" + "time": "2025-10-29T15:56:20+00:00" } ], "aliases": [], diff --git a/config/trustedproxy.php b/config/trustedproxy.php deleted file mode 100644 index 8557288..0000000 --- a/config/trustedproxy.php +++ /dev/null @@ -1,6 +0,0 @@ - ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)), -]; diff --git a/database/factories/DevicePaletteFactory.php b/database/factories/DevicePaletteFactory.php index 1d7ed2d..a672873 100644 --- a/database/factories/DevicePaletteFactory.php +++ b/database/factories/DevicePaletteFactory.php @@ -20,7 +20,7 @@ class DevicePaletteFactory extends Factory public function definition(): array { return [ - 'id' => 'test-'.$this->faker->unique()->slug(), + 'id' => 'test-' . $this->faker->unique()->slug(), 'name' => $this->faker->words(3, true), 'grays' => $this->faker->randomElement([2, 4, 16, 256]), 'colors' => $this->faker->optional()->passthrough([ diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php index 10a1580..a2d2e65 100644 --- a/database/factories/PluginFactory.php +++ b/database/factories/PluginFactory.php @@ -29,24 +29,8 @@ 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 deleted file mode 100644 index 558fe2c..0000000 --- a/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index d230657..0000000 --- a/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 3b9b1b7..0000000 --- a/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php +++ /dev/null @@ -1,58 +0,0 @@ -selectRaw('user_id, trmnlp_id, COUNT(*) as duplicate_count') - ->whereNotNull('trmnlp_id') - ->groupBy('user_id', 'trmnlp_id') - ->havingRaw('COUNT(*) > ?', [1]) - ->get(); - - // For each duplicate combination, keep the first one (by id) and set others to null - foreach ($duplicates as $duplicate) { - $plugins = Plugin::query() - ->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; - } - - $plugin->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 deleted file mode 100644 index 0a527d7..0000000 --- a/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('alias')->default(false); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('plugins', function (Blueprint $table) { - $table->dropColumn('alias'); - }); - } -}; diff --git a/database/seeders/ExampleRecipesSeeder.php b/database/seeders/ExampleRecipesSeeder.php index 890eed9..5474615 100644 --- a/database/seeders/ExampleRecipesSeeder.php +++ b/database/seeders/ExampleRecipesSeeder.php @@ -13,8 +13,8 @@ class ExampleRecipesSeeder extends Seeder public function run($user_id = 1): void { Plugin::updateOrCreate( - ['uuid' => '9e46c6cf-358c-4bfe-8998-436b3a207fec'], [ + 'uuid' => '9e46c6cf-358c-4bfe-8998-436b3a207fec', 'name' => 'ÖBB Departures', 'user_id' => $user_id, 'data_payload' => null, @@ -32,8 +32,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '3b046eda-34e9-4232-b935-c33b989a284b'], [ + 'uuid' => '3b046eda-34e9-4232-b935-c33b989a284b', 'name' => 'Weather', 'user_id' => $user_id, 'data_payload' => null, @@ -51,8 +51,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54'], [ + 'uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54', 'name' => 'Zen Quotes', 'user_id' => $user_id, 'data_payload' => null, @@ -70,8 +70,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f'], [ + 'uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f', 'name' => 'This Day in History', 'user_id' => $user_id, 'data_payload' => null, @@ -89,8 +89,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d'], [ + 'uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d', 'name' => 'Home Assistant', 'user_id' => $user_id, 'data_payload' => null, @@ -108,8 +108,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80'], [ + 'uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80', 'name' => 'Sunrise/Sunset', 'user_id' => $user_id, 'data_payload' => null, @@ -127,8 +127,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe'], [ + 'uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe', 'name' => 'Pollen Forecast', 'user_id' => $user_id, 'data_payload' => null, @@ -146,8 +146,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '1d98bca4-837d-4b01-b1a1-e3b6e56eca90'], [ + 'uuid' => '1d98bca4-837d-4b01-b1a1-e3b6e56eca90', 'name' => 'Holidays (iCal)', 'user_id' => $user_id, 'data_payload' => null, diff --git a/package-lock.json b/package-lock.json index e722432..8411d6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "laravel", + "name": "laravel-trmnl-server", "lockfileVersion": 3, "requires": true, "packages": { @@ -156,6 +156,7 @@ "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", @@ -192,6 +193,7 @@ "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" } @@ -213,6 +215,7 @@ "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", @@ -715,6 +718,7 @@ "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" } @@ -1610,6 +1614,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1893,7 +1898,8 @@ "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" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -2945,6 +2951,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2971,6 +2978,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3421,6 +3429,7 @@ "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 deleted file mode 100644 index 5e51318..0000000 Binary files a/public/mirror/assets/apple-touch-icon-120x120.png and /dev/null differ diff --git a/public/mirror/assets/apple-touch-icon-152x152.png b/public/mirror/assets/apple-touch-icon-152x152.png deleted file mode 100644 index 9f8d9e3..0000000 Binary files a/public/mirror/assets/apple-touch-icon-152x152.png and /dev/null differ diff --git a/public/mirror/assets/apple-touch-icon-167x167.png b/public/mirror/assets/apple-touch-icon-167x167.png deleted file mode 100644 index 79d1211..0000000 Binary files a/public/mirror/assets/apple-touch-icon-167x167.png and /dev/null differ diff --git a/public/mirror/assets/apple-touch-icon-180x180.png b/public/mirror/assets/apple-touch-icon-180x180.png deleted file mode 100644 index 0499ff4..0000000 Binary files a/public/mirror/assets/apple-touch-icon-180x180.png and /dev/null differ diff --git a/public/mirror/assets/apple-touch-icon-76x76.png b/public/mirror/assets/apple-touch-icon-76x76.png deleted file mode 100644 index df3943a..0000000 Binary files a/public/mirror/assets/apple-touch-icon-76x76.png and /dev/null differ diff --git a/public/mirror/assets/favicon-16x16.png b/public/mirror/assets/favicon-16x16.png deleted file mode 100644 index b36f23b..0000000 Binary files a/public/mirror/assets/favicon-16x16.png and /dev/null differ diff --git a/public/mirror/assets/favicon-32x32.png b/public/mirror/assets/favicon-32x32.png deleted file mode 100644 index ae12e60..0000000 Binary files a/public/mirror/assets/favicon-32x32.png and /dev/null differ diff --git a/public/mirror/assets/favicon.ico b/public/mirror/assets/favicon.ico deleted file mode 100644 index da17cd5..0000000 Binary files a/public/mirror/assets/favicon.ico and /dev/null differ diff --git a/public/mirror/assets/logo--brand.svg b/public/mirror/assets/logo--brand.svg deleted file mode 100644 index 1b84f50..0000000 --- a/public/mirror/assets/logo--brand.svg +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/mirror/index.html b/public/mirror/index.html deleted file mode 100644 index 64746fe..0000000 --- a/public/mirror/index.html +++ /dev/null @@ -1,521 +0,0 @@ - - - - - - TRMNL BYOS Laravel Mirror - - - - - - - - - - - - - - - - - - - -
-
- - - -
- - - \ No newline at end of file diff --git a/public/mirror/manifest.json b/public/mirror/manifest.json deleted file mode 100644 index 4d44e44..0000000 --- a/public/mirror/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "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 de95b81..46b9ca1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -59,10 +59,6 @@ @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] { @@ -72,39 +68,3 @@ 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 f23389f..c77bf3d 100644 --- a/resources/js/codemirror-core.js +++ b/resources/js/codemirror-core.js @@ -1,9 +1,8 @@ import { EditorView, lineNumbers, keymap } from '@codemirror/view'; import { ViewPlugin } from '@codemirror/view'; -import { indentWithTab, selectAll } from '@codemirror/commands'; +import { indentWithTab } 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'; @@ -155,16 +154,7 @@ export function createCodeMirror(element, options = {}) { createResizePlugin(), ...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]), ...themeSupport, - keymap.of([ - indentWithTab, - ...foldKeymap, - ...historyKeymap, - ...searchKeymap, - { - key: 'Mod-a', - run: selectAll, - }, - ]), + keymap.of([indentWithTab, ...foldKeymap, ...historyKeymap]), 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 b5a62c6..1a316ef 100644 --- a/resources/views/components/layouts/auth/card.blade.php +++ b/resources/views/components/layouts/auth/card.blade.php @@ -15,7 +15,7 @@
-
+
{{ $slot }}
diff --git a/resources/views/default-screens/error.blade.php b/resources/views/default-screens/error.blade.php deleted file mode 100644 index be8063a..0000000 --- a/resources/views/default-screens/error.blade.php +++ /dev/null @@ -1,23 +0,0 @@ -@props([ - 'noBleed' => false, - 'darkMode' => false, - 'deviceVariant' => 'og', - 'deviceOrientation' => null, - 'colorDepth' => '1bit', - 'scaleLevel' => null, - 'pluginName' => 'Recipe', -]) - - - - - - Error on {{ $pluginName }} - Unable to render content. Please check server logs. - - - - - diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index fdf7f34..201ee7e 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -1,26 +1,20 @@ loadCatalogPlugins(); @@ -55,7 +49,7 @@ class extends Component return collect($catalog) ->filter(function ($plugin) use ($currentVersion) { // Check if Laravel compatibility is true - if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) { + if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) { return false; } @@ -85,9 +79,8 @@ class extends Component }) ->sortBy('name') ->toArray(); - } catch (Exception $e) { - Log::error('Failed to load catalog from URL: '.$e->getMessage()); - + } catch (\Exception $e) { + Log::error('Failed to load catalog from URL: ' . $e->getMessage()); return []; } }); @@ -99,9 +92,8 @@ class extends Component $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); - if (! $plugin || ! $plugin['zip_url']) { + if (!$plugin || !$plugin['zip_url']) { $this->addError('installation', 'Plugin not found or no download URL available.'); - return; } @@ -113,45 +105,24 @@ class extends Component auth()->user(), $plugin['zip_entry_path'] ?? null, null, - $plugin['logo_url'] ?? null, - allowDuplicate: true + $plugin['logo_url'] ?? null ); $this->dispatch('plugin-installed'); Flux::modal('import-from-catalog')->close(); - } catch (Exception $e) { - $this->addError('installation', 'Error installing plugin: '.$e->getMessage()); + } catch (\Exception $e) { + $this->addError('installation', 'Error installing plugin: ' . $e->getMessage()); } finally { $this->installingPlugin = ''; } } - - public function previewPlugin(string $pluginId): void - { - $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); - - if (! $plugin) { - $this->addError('preview', 'Plugin not found.'); - - return; - } - - $this->previewingPlugin = $pluginId; - $this->previewData = $plugin; - } - - public function closePreview(): void - { - $this->previewingPlugin = ''; - $this->previewData = []; - } }; ?>
@if(empty($catalogPlugins))
- + No plugins available Catalog is empty
@@ -162,30 +133,30 @@ class extends Component @enderror @foreach($catalogPlugins as $plugin) -
+
@if($plugin['logo_url']) {{ $plugin['name'] }} @else -
- +
+
@endif
- {{ $plugin['name'] }} +

{{ $plugin['name'] }}

@if ($plugin['github']) - by {{ $plugin['github'] }} +

by {{ $plugin['github'] }}

@endif
@if($plugin['license']) - {{ $plugin['license'] }} + {{ $plugin['license'] }} @endif @if($plugin['repo_url']) - + @endif @@ -193,7 +164,7 @@ class extends Component
@if($plugin['description']) - {{ $plugin['description'] }} +

{{ $plugin['description'] }}

@endif
@@ -203,19 +174,6 @@ class extends Component Install - @if($plugin['screenshot_url']) - - - Preview - - - @endif - - - @if($plugin['learn_more_url']) @endif - - - - @if($previewingPlugin && !empty($previewData)) -
- Preview {{ $previewData['name'] ?? 'Plugin' }} -
- -
-
- Preview of {{ $previewData['name'] }} -
- - @if($previewData['description']) -
- Description - {{ $previewData['description'] }} -
- @endif - -
- - - Install Plugin - - -
-
- @endif -
diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index cc8b070..8e9c7af 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -1,30 +1,20 @@ loadNewest(); @@ -47,36 +37,22 @@ class extends Component private function loadNewest(): void { try { - $cacheKey = 'trmnl_recipes_newest_page_'.$this->page; - $response = Cache::remember($cacheKey, 43200, function () { + $this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () { $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ 'sort-by' => 'newest', - 'page' => $this->page, ]); - if (! $response->successful()) { - throw new RuntimeException('Failed to fetch TRMNL recipes'); + if (!$response->successful()) { + throw new \RuntimeException('Failed to fetch TRMNL recipes'); } - return $response->json(); + $json = $response->json(); + $data = $json['data'] ?? []; + return $this->mapRecipes($data); }); - - $data = $response['data'] ?? []; - $mapped = $this->mapRecipes($data); - - if ($this->page === 1) { - $this->recipes = $mapped; - } else { - $this->recipes = array_merge($this->recipes, $mapped); - } - - $this->hasMore = ! empty($response['next_page_url']); - } catch (Throwable $e) { - Log::error('TRMNL catalog load error: '.$e->getMessage()); - if ($this->page === 1) { - $this->recipes = []; - } - $this->hasMore = false; + } catch (\Throwable $e) { + Log::error('TRMNL catalog load error: ' . $e->getMessage()); + $this->recipes = []; } } @@ -84,65 +60,38 @@ class extends Component { $this->isSearching = true; try { - $cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page; - $response = Cache::remember($cacheKey, 300, function () use ($term) { + $cacheKey = 'trmnl_recipes_search_' . md5($term); + $this->recipes = Cache::remember($cacheKey, 300, function () use ($term) { $response = Http::get('https://usetrmnl.com/recipes.json', [ 'search' => $term, 'sort-by' => 'newest', - 'page' => $this->page, ]); - if (! $response->successful()) { - throw new RuntimeException('Failed to search TRMNL recipes'); + if (!$response->successful()) { + throw new \RuntimeException('Failed to search TRMNL recipes'); } - return $response->json(); + $json = $response->json(); + $data = $json['data'] ?? []; + return $this->mapRecipes($data); }); - - $data = $response['data'] ?? []; - $mapped = $this->mapRecipes($data); - - if ($this->page === 1) { - $this->recipes = $mapped; - } else { - $this->recipes = array_merge($this->recipes, $mapped); - } - - $this->hasMore = ! empty($response['next_page_url']); - } catch (Throwable $e) { - Log::error('TRMNL catalog search error: '.$e->getMessage()); - if ($this->page === 1) { - $this->recipes = []; - } - $this->hasMore = false; + } catch (\Throwable $e) { + Log::error('TRMNL catalog search error: ' . $e->getMessage()); + $this->recipes = []; } finally { $this->isSearching = false; } } - public function loadMore(): void - { - $this->page++; - - $term = mb_trim($this->search); - if ($term === '' || mb_strlen($term) < 2) { - $this->loadNewest(); - } else { - $this->searchRecipes($term); - } - } - public function updatedSearch(): void { - $this->page = 1; - $term = mb_trim($this->search); + $term = trim($this->search); if ($term === '') { $this->loadNewest(); - return; } - if (mb_strlen($term) < 2) { + if (strlen($term) < 2) { // Require at least 2 chars to avoid noisy calls return; } @@ -164,85 +113,43 @@ class extends Component auth()->user(), null, config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null, - $recipe['icon_url'] ?? null, - allowDuplicate: true + $recipe['icon_url'] ?? null ); $this->dispatch('plugin-installed'); Flux::modal('import-from-trmnl-catalog')->close(); - } catch (Exception $e) { - Log::error('Plugin installation failed: '.$e->getMessage()); - $this->addError('installation', 'Error installing plugin: '.$e->getMessage()); - } - } - - public function previewRecipe(string $recipeId): void - { - $this->previewingRecipe = $recipeId; - $this->previewData = []; - - try { - $response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json"); - - if ($response->successful()) { - $item = $response->json()['data'] ?? []; - $this->previewData = $this->mapRecipe($item); - } else { - // Fallback to searching for the specific recipe if single endpoint doesn't exist - $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ - 'search' => $recipeId, - ]); - - if ($response->successful()) { - $data = $response->json()['data'] ?? []; - $item = collect($data)->firstWhere('id', $recipeId); - if ($item) { - $this->previewData = $this->mapRecipe($item); - } - } - } - } catch (Throwable $e) { - Log::error('TRMNL catalog preview fetch error: '.$e->getMessage()); - } - - if (empty($this->previewData)) { - $this->previewData = collect($this->recipes)->firstWhere('id', $recipeId) ?? []; + } catch (\Exception $e) { + Log::error('Plugin installation failed: ' . $e->getMessage()); + $this->addError('installation', 'Error installing plugin: ' . $e->getMessage()); } } /** - * @param array> $items + * @param array> $items * @return array> */ private function mapRecipes(array $items): array { return collect($items) - ->map(fn (array $item) => $this->mapRecipe($item)) + ->map(function (array $item) { + return [ + 'id' => $item['id'] ?? null, + 'name' => $item['name'] ?? 'Untitled', + 'icon_url' => $item['icon_url'] ?? null, + 'screenshot_url' => $item['screenshot_url'] ?? null, + 'author_bio' => is_array($item['author_bio'] ?? null) + ? strip_tags($item['author_bio']['description'] ?? null) + : null, + 'stats' => [ + 'installs' => data_get($item, 'stats.installs'), + 'forks' => data_get($item, 'stats.forks'), + ], + 'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/' . $item['id']) : null, + ]; + }) ->toArray(); } - - /** - * @param array $item - * @return array - */ - private function mapRecipe(array $item): array - { - return [ - 'id' => $item['id'] ?? null, - 'name' => $item['name'] ?? 'Untitled', - 'icon_url' => $item['icon_url'] ?? null, - 'screenshot_url' => $item['screenshot_url'] ?? null, - 'author_bio' => is_array($item['author_bio'] ?? null) - ? strip_tags($item['author_bio']['description'] ?? null) - : null, - 'stats' => [ - 'installs' => data_get($item, 'stats.installs'), - 'forks' => data_get($item, 'stats.forks'), - ], - 'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null, - ]; - } }; ?>
@@ -254,7 +161,7 @@ class extends Component icon="magnifying-glass" />
- Newest + Newest
@error('installation') @@ -263,36 +170,35 @@ class extends Component @if(empty($recipes))
- + No recipes found Try a different search term
@else
@foreach($recipes as $recipe) -
-
-
+
+
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) @if($thumb) {{ $recipe['name'] }} @else -
- +
+
@endif
- {{ $recipe['name'] }} +

{{ $recipe['name'] }}

@if(data_get($recipe, 'stats.installs')) - Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} +

Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}

@endif
@if($recipe['detail_url']) - + @endif @@ -300,7 +206,7 @@ class extends Component
@if($recipe['author_bio']) - {{ $recipe['author_bio'] }} +

{{ $recipe['author_bio'] }}

@endif
@@ -312,96 +218,19 @@ class extends Component @endif - @if($recipe['id'] && ($recipe['screenshot_url'] ?? null)) - - - Preview - - + @if($recipe['detail_url']) + + View on TRMNL + @endif
-
@endforeach
- - @if($hasMore) -
- - Load next page - Loading... - -
- @endif @endif - - - -
-
- - Fetching recipe details... -
-
- -
- @if($previewingRecipe && !empty($previewData)) -
- Preview {{ $previewData['name'] ?? 'Recipe' }} -
- -
-
- Preview of {{ $previewData['name'] }} -
- - @if($previewData['author_bio']) -
-
- Description - {{ $previewData['author_bio'] }} -
-
- @endif - - @if(data_get($previewData, 'stats.installs')) -
-
- Statistics - - Installs: {{ data_get($previewData, 'stats.installs') }} · - Forks: {{ data_get($previewData, 'stats.forks') }} - -
-
- @endif - -
- @if($previewData['detail_url']) - - View on TRMNL - - @endif - - - Install Recipe - - -
-
- @endif -
-
diff --git a/resources/views/livewire/device-dashboard.blade.php b/resources/views/livewire/device-dashboard.blade.php index 7fd48a8..5db65d1 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="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">

Add your first device

+ class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
@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 f9d49ca..30b4481 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="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
@php $current_image_uuid =$device->current_screen_image; @@ -368,10 +368,6 @@ new class extends Component { Update Firmware Show Logs - - Mirror URL - - Delete Device @@ -502,26 +498,6 @@ 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 6c979e6..3e786b4 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 deleted file mode 100644 index 7aaacbb..0000000 --- a/resources/views/livewire/plugins/config-modal.blade.php +++ /dev/null @@ -1,516 +0,0 @@ - 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 -
- -
- @if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields'])) - @foreach($configuration_template['custom_fields'] as $field) - @php - $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; - $rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? ''); - - # These are sanitized at Model/Plugin level, safe to render HTML - $safeDescription = $field['description'] ?? ''; - $safeHelp = $field['help_text'] ?? ''; - - // For code fields, if the value is an array, JSON encode it - if ($field['field_type'] === 'code' && is_array($rawValue)) { - $currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } else { - $currentValue = is_array($rawValue) ? '' : (string) $rawValue; - } - @endphp -
- @if($field['field_type'] === 'author_bio') - @continue - @endif - - @if($field['field_type'] === 'copyable_webhook_url') - @continue - @endif - - @if($field['field_type'] === 'string' || $field['field_type'] === 'url') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'text') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'code') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'password') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'copyable') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'time_zone') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @foreach(timezone_identifiers_list() as $timezone) - - @endforeach - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'number') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'boolean') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'date') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'time') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'select') - @if(isset($field['multiple']) && $field['multiple'] === true) - - {{ $field['name'] }} - {!! $safeDescription !!} - - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) - @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach - @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - - @endif - @endforeach - @endif - - {!! $safeHelp !!} - - @else - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) - @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach - @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - - @endif - @endforeach - @endif - - {!! $safeHelp !!} - - @endif - - @elseif($field['field_type'] === 'xhrSelect') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'xhrSelectSearch') -
- - {{ $field['name'] }} - {!! $safeDescription !!} - - - - - {!! $safeHelp !!} - @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - @if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey]))) - {{-- Show current value even if no options are loaded --}} - - @endif - - @endif -
- @elseif($field['field_type'] === 'multi_string') - - {{ $field['name'] }} - {!! $safeDescription !!} - -
- @foreach($multiValues[$fieldKey] as $index => $item) -
- - - - @if(count($multiValues[$fieldKey]) > 1) - - @endif -
- @error("multiValues.{$fieldKey}.{$index}") -
- - {{-- $message comes from thrown error --}} - {{ $message }} -
- @enderror - @endforeach - - - Add Item - -
- {!! $safeHelp !!} -
- @else - Field type "{{ $field['field_type'] }}" not yet supported - @endif -
- @endforeach - @endif - -
- - - Save Configuration - - @if($errors->any()) -
- - - Fix errors before saving. - -
- @endif -
-
-
-
-
diff --git a/resources/views/livewire/plugins/image-webhook-instance.blade.php b/resources/views/livewire/plugins/image-webhook-instance.blade.php deleted file mode 100644 index e4ad9df..0000000 --- a/resources/views/livewire/plugins/image-webhook-instance.blade.php +++ /dev/null @@ -1,298 +0,0 @@ -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 -
- -
- -
- - @foreach(auth()->user()->devices as $device) - - @endforeach - -
- - @if(count($checked_devices) > 0) - -
- @foreach($checked_devices as $deviceId) - @php - $device = auth()->user()->devices->find($deviceId); - @endphp -
-
- {{ $device->name }} -
- -
- - - @foreach($this->getDevicePlaylists($deviceId) as $playlist) - - @endforeach - - -
- - @if(isset($device_playlists[$deviceId]) && $device_playlists[$deviceId] === 'new') -
-
- -
-
- - - - - - - - - -
-
-
- -
-
- -
-
-
- @endif -
- @endforeach -
- @endif - - -
- - Add to Playlist -
- -
-
- - -
- Delete {{ $plugin->name }}? -

This will also remove this instance from your playlists.

-
- -
- - - Cancel - - Delete instance -
-
- -
-
-
-
- -
- -
- - Save -
-
- -
- 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()) - {{ $plugin->name }} - @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 deleted file mode 100644 index 3161443..0000000 --- a/resources/views/livewire/plugins/image-webhook.blade.php +++ /dev/null @@ -1,163 +0,0 @@ - '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 -
- -
-
- -
- -
- - Create Instance -
-
-
-
- - @if(empty($instances)) -
- - No instances yet - Create your first Image Webhook instance to get started. - -
- @else - - - - - - - - - - @foreach($instances as $instance) - - - - - @endforeach - -
-
Name
-
-
Actions
-
- {{ $instance['name'] }} - -
- - - - - - - - -
-
- @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 d902183..469365c 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -26,8 +26,6 @@ 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 = [ @@ -42,12 +40,7 @@ new class extends Component { public function refreshPlugins(): void { - // 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(); + $userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray(); $allPlugins = array_merge($this->native_plugins, $userPlugins ?? []); $allPlugins = array_values($allPlugins); $allPlugins = $this->sortPlugins($allPlugins); @@ -395,7 +388,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="styled-container"> + class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 0e29e76..4be96cc 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -1,16 +1,12 @@ 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 { @@ -77,12 +74,6 @@ 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 @@ -138,19 +129,6 @@ 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 @@ -276,6 +254,39 @@ 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(); @@ -296,6 +307,8 @@ new class extends Component { return $this->configuration[$key] ?? $default; } + + public function renderExample(string $example) { switch ($example) { @@ -364,17 +377,13 @@ 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 { - // Create a device object with og_plus model and the selected bitdepth - $device = $this->createPreviewDevice(); - $previewMarkup = $this->plugin->render($size, true, $device); + $previewMarkup = $this->plugin->render($size); $this->dispatch('preview-updated', preview: $previewMarkup); } catch (LiquidException $e) { $this->dispatch('preview-error', message: $e->toLiquidErrorMessage()); @@ -383,38 +392,6 @@ 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); @@ -422,31 +399,42 @@ HTML; $this->redirect(route('plugins.index')); } - #[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(); - } + public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - // Laravel Livewire computed property: access with $this->parsed_urls - #[Computed] - private function parsedUrls() - { - if (!isset($this->polling_url)) { - return null; + 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] = []; + } } - try { - return $this->plugin->resolveLiquidVariables($this->polling_url); - - } catch (\Exception $e) { - return 'PARSE_ERROR: ' . $e->getMessage(); + public function searchXhrSelect(string $fieldKey, string $endpoint): void + { + $query = $this->searchQueries[$fieldKey] ?? ''; + if (!empty($query)) { + $this->loadXhrSelectOptions($fieldKey, $endpoint, $query); + } } - } - } + ?>
@@ -478,6 +466,7 @@ HTML; + @@ -487,11 +476,6 @@ HTML; - - Recipe Settings - - - Duplicate Plugin Delete Plugin @@ -633,15 +617,8 @@ HTML; -
+
Preview {{ $plugin->name }} - - - @foreach($this->getDeviceModels() as $model) - - @endforeach - -
@@ -649,9 +626,269 @@ HTML;
- + +
+
+ Configuration + Configure your plugin settings +
- +
+ @if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields'])) + @foreach($configuration_template['custom_fields'] as $field) + @php + $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; + $rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? ''); + + // For code fields, if the value is an array, JSON encode it + if ($field['field_type'] === 'code' && is_array($rawValue)) { + $currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } else { + $currentValue = is_array($rawValue) ? '' : (string) $rawValue; + } + @endphp +
+ @if($field['field_type'] === 'author_bio') + @continue + @endif + + @if($field['field_type'] === 'copyable_webhook_url') + @continue + @endif + + @if($field['field_type'] === 'string' || $field['field_type'] === 'url') + + @elseif($field['field_type'] === 'text') + + @elseif($field['field_type'] === 'code') + + @elseif($field['field_type'] === 'password') + + @elseif($field['field_type'] === 'copyable') + + @elseif($field['field_type'] === 'time_zone') + + + @foreach(timezone_identifiers_list() as $timezone) + + @endforeach + + @elseif($field['field_type'] === 'number') + + @elseif($field['field_type'] === 'boolean') + + @elseif($field['field_type'] === 'date') + + @elseif($field['field_type'] === 'time') + + @elseif($field['field_type'] === 'select') + @if(isset($field['multiple']) && $field['multiple'] === true) + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + @else + + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + @endif + @elseif($field['field_type'] === 'xhrSelect') + + + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) + @if(is_array($option)) + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif + @else + + @endif + @endforeach + @endif + + @elseif($field['field_type'] === 'xhrSelectSearch') +
+ + {{ $field['name'] }} + {{ $field['description'] ?? '' }} + + + + + {{ $field['help_text'] ?? '' }} + @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) + + + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) + @if(is_array($option)) + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif + @else + + @endif + @endforeach + @endif + @if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey]))) + {{-- Show current value even if no options are loaded --}} + + @endif + + @endif +
+ @elseif($field['field_type'] === 'multi_string') + + @else + Field type "{{ $field['field_type'] }}" not yet supported + @endif +
+ @endforeach + @endif + +
+ + Save Configuration +
+
+
+

Settings

@@ -739,7 +976,7 @@ HTML; @endif
- Configuration Fields + Configuration
@endif @@ -752,62 +989,15 @@ HTML;
@if($data_strategy === 'polling') - Polling URL - -
-
- - - -
- -
- - -
- - Preview computed URLs here (readonly): - - {{ $this->parsed_urls }} - - -
- - + class="block w-full" type="text" name="polling_url" autofocus> + + Fetch data now
-
@@ -950,7 +1140,7 @@ HTML; />
diff --git a/resources/views/livewire/plugins/recipes/settings.blade.php b/resources/views/livewire/plugins/recipes/settings.blade.php deleted file mode 100644 index 8ae3d6f..0000000 --- a/resources/views/livewire/plugins/recipes/settings.blade.php +++ /dev/null @@ -1,104 +0,0 @@ -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 -
- -
-
- {{-- --}} - - TRMNLP Recipe ID - - - Recipe ID in the TRMNL Recipe Catalog. If set, it can be used with trmnlp. - - - - - Enable an Alias URL for this recipe. Your server does not need to be exposed to the internet, but your device must be able to reach the URL. Docs - - - @if($alias) - - Alias URL - - Copy this URL to your TRMNL Dashboard. By default, image is created for TRMNL OG; use parameter ?device-model= to specify a device model. - - @endif -
- -
- - - Cancel - - Save -
-
-
-
diff --git a/resources/views/recipes/holidays-ical.blade.php b/resources/views/recipes/holidays-ical.blade.php index 454709d..f5f5403 100644 --- a/resources/views/recipes/holidays-ical.blade.php +++ b/resources/views/recipes/holidays-ical.blade.php @@ -2,46 +2,36 @@ @php use Carbon\Carbon; - $today = Carbon::today(config('app.timezone')); - $events = collect($data['ical'] ?? []) ->map(function (array $event): array { + $start = null; + $end = null; + try { - $start = isset($event['DTSTART']) - ? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone')) - : null; + $start = isset($event['DTSTART']) ? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone')) : null; } catch (Exception $e) { $start = null; } try { - $end = isset($event['DTEND']) - ? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone')) - : null; + $end = isset($event['DTEND']) ? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone')) : null; } catch (Exception $e) { $end = null; } return [ - 'summary' => $event['SUMMARY'] ?? 'Untitled event', - 'location' => $event['LOCATION'] ?? '—', - 'start' => $start, - 'end' => $end, + 'summary' => $event['SUMMARY'] ?? 'Untitled event', + 'location' => $event['LOCATION'] ?? null, + 'start' => $start, + 'end' => $end, ]; }) - ->filter(fn ($event) => - $event['start'] && - ( - $event['start']->greaterThanOrEqualTo($today) || - ($event['end'] && $event['end']->greaterThanOrEqualTo($today)) - ) - ) + ->filter(fn ($event) => $event['start']) ->sortBy('start') ->take($size === 'quadrant' ? 5 : 8) ->values(); @endphp - diff --git a/resources/views/recipes/zen.blade.php b/resources/views/recipes/zen.blade.php index 0ae920f..5e01eac 100644 --- a/resources/views/recipes/zen.blade.php +++ b/resources/views/recipes/zen.blade.php @@ -3,11 +3,11 @@ -
{{$data['data'][0]['a'] ?? ''}}
- @if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant') -

{{ $data['data'][0]['q'] ?? '' }}

+
{{$data[0]['a']}}
+ @if (strlen($data[0]['q']) < 300 && $size != 'quadrant') +

{{ $data[0]['q'] }}

@else -

{{ $data['data'][0]['q'] ?? '' }}

+

{{ $data[0]['q'] }}

@endif
diff --git a/routes/api.php b/routes/api.php index d201312..9721a0f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,16 +18,18 @@ use Illuminate\Support\Str; Route::get('/display', function (Request $request) { $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('api_key', $access_token)->first(); + $device = Device::where('mac_address', $mac_address) + ->where('api_key', $access_token) + ->first(); if (! $device) { // Check if there's a user with assign_new_devices enabled $auto_assign_user = User::where('assign_new_devices', true)->first(); - if ($auto_assign_user && $mac_address) { + if ($auto_assign_user) { // Create a new device and assign it to this user $device = Device::create([ - 'mac_address' => mb_strtoupper($mac_address ?? ''), + 'mac_address' => $mac_address, 'api_key' => $access_token, 'user_id' => $auto_assign_user->id, 'name' => "{$auto_assign_user->name}'s TRMNL", @@ -37,7 +39,7 @@ Route::get('/display', function (Request $request) { ]); } else { return response()->json([ - 'message' => 'MAC Address not registered (or not set), or invalid access token', + 'message' => 'MAC Address not registered or invalid access token', ], 404); } } @@ -93,16 +95,9 @@ Route::get('/display', function (Request $request) { // Check and update stale data if needed if ($plugin->isDataStale() || $plugin->current_image === null) { $plugin->updateDataPayload(); - try { - $markup = $plugin->render(device: $device); + $markup = $plugin->render(device: $device); - GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); - } catch (Exception $e) { - Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage()); - // Generate error display - $errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name); - $device->update(['current_screen_image' => $errorImageUuid]); - } + GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); } $plugin->refresh(); @@ -125,17 +120,8 @@ Route::get('/display', function (Request $request) { } } - try { - $markup = $playlistItem->render(device: $device); - GenerateScreenJob::dispatchSync($device->id, null, $markup); - } catch (Exception $e) { - Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage()); - // For mashups, show error for the first plugin or a generic error - $firstPlugin = $plugins->first(); - $pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe'; - $errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName); - $device->update(['current_screen_image' => $errorImageUuid]); - } + $markup = $playlistItem->render(device: $device); + GenerateScreenJob::dispatchSync($device->id, null, $markup); $device->refresh(); @@ -218,7 +204,7 @@ Route::get('/setup', function (Request $request) { ], 404); } - $device = Device::where('mac_address', mb_strtoupper($mac_address))->first(); + $device = Device::where('mac_address', $mac_address)->first(); if (! $device) { // Check if there's a user with assign_new_devices enabled @@ -233,7 +219,7 @@ Route::get('/setup', function (Request $request) { // Create a new device and assign it to this user $device = Device::create([ - 'mac_address' => mb_strtoupper($mac_address), + 'mac_address' => $mac_address, 'api_key' => Str::random(22), 'user_id' => $auto_assign_user->id, 'name' => "{$auto_assign_user->name}'s TRMNL", @@ -359,7 +345,7 @@ Route::post('/display/update', function (Request $request) { Route::post('/screens', function (Request $request) { $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('mac_address', mb_strtoupper($mac_address ?? '')) + $device = Device::where('mac_address', $mac_address) ->where('api_key', $access_token) ->first(); @@ -547,91 +533,6 @@ 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([ @@ -676,90 +577,3 @@ 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 b3069bd..7b7868d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -31,8 +31,6 @@ 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/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index c98cb2f..726f313 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -7,7 +7,6 @@ use App\Models\Playlist; use App\Models\PlaylistItem; use App\Models\Plugin; use App\Models\User; -use App\Services\ImageGenerationService; use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; @@ -263,7 +262,7 @@ test('invalid device credentials return error', function (): void { ])->get('/api/display'); $response->assertNotFound() - ->assertJson(['message' => 'MAC Address not registered (or not set), or invalid access token']); + ->assertJson(['message' => 'MAC Address not registered or invalid access token']); }); test('log endpoint requires valid device credentials', function (): void { @@ -955,232 +954,3 @@ test('setup endpoint handles non-existent device model gracefully', function (): expect($device)->not->toBeNull() ->and($device->device_model_id)->toBeNull(); }); - -test('setup endpoint matches MAC address case-insensitively', function (): void { - // Create device with lowercase MAC address - $device = Device::factory()->create([ - 'mac_address' => 'a1:b2:c3:d4:e5:f6', - 'api_key' => 'test-api-key', - 'friendly_id' => 'test-device', - ]); - - // Request with uppercase MAC address should still match - $response = $this->withHeaders([ - 'id' => 'A1:B2:C3:D4:E5:F6', - ])->get('/api/setup'); - - $response->assertOk() - ->assertJson([ - 'status' => 200, - 'api_key' => 'test-api-key', - 'friendly_id' => 'test-device', - 'message' => 'Welcome to TRMNL BYOS', - ]); -}); - -test('display endpoint matches MAC address case-insensitively', function (): void { - // Create device with lowercase MAC address - $device = Device::factory()->create([ - 'mac_address' => 'a1:b2:c3:d4:e5:f6', - 'api_key' => 'test-api-key', - 'current_screen_image' => 'test-image', - ]); - - // Request with uppercase MAC address should still match - $response = $this->withHeaders([ - 'id' => 'A1:B2:C3:D4:E5:F6', - 'access-token' => $device->api_key, - 'rssi' => -70, - 'battery_voltage' => 3.8, - 'fw-version' => '1.0.0', - ])->get('/api/display'); - - $response->assertOk() - ->assertJson([ - 'status' => '0', - 'filename' => 'test-image.bmp', - ]); -}); - -test('screens endpoint matches MAC address case-insensitively', function (): void { - Queue::fake(); - - // Create device with uppercase MAC address - $device = Device::factory()->create([ - 'mac_address' => 'A1:B2:C3:D4:E5:F6', - 'api_key' => 'test-api-key', - ]); - - // Request with lowercase MAC address should still match - $response = $this->withHeaders([ - 'id' => 'a1:b2:c3:d4:e5:f6', - 'access-token' => $device->api_key, - ])->post('/api/screens', [ - 'image' => [ - 'content' => '
Test content
', - ], - ]); - - $response->assertOk(); - Queue::assertPushed(GenerateScreenJob::class); -}); - -test('display endpoint handles plugin rendering errors gracefully', function (): void { - TrmnlPipeline::fake(); - - $device = Device::factory()->create([ - 'mac_address' => '00:11:22:33:44:55', - 'api_key' => 'test-api-key', - 'proxy_cloud' => false, - ]); - - // Create a plugin with Blade markup that will cause an exception when accessing data[0] - // when data is not an array or doesn't have index 0 - $plugin = Plugin::factory()->create([ - 'name' => 'Broken Recipe', - 'data_strategy' => 'polling', - 'polling_url' => null, - 'data_stale_minutes' => 1, - 'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access - 'render_markup' => '
{{ $data[0]["invalid"] }}
', // This will fail if data[0] doesn't exist - 'data_payload' => ['error' => 'Failed to fetch data'], // Not a list, so data[0] will fail - 'data_payload_updated_at' => now()->subMinutes(2), // Make it stale - 'current_image' => null, - ]); - - $playlist = Playlist::factory()->create([ - 'device_id' => $device->id, - 'name' => 'test_playlist', - 'is_active' => true, - 'weekdays' => null, - 'active_from' => null, - 'active_until' => null, - ]); - - PlaylistItem::factory()->create([ - 'playlist_id' => $playlist->id, - 'plugin_id' => $plugin->id, - 'order' => 1, - 'is_active' => true, - 'last_displayed_at' => null, - ]); - - $response = $this->withHeaders([ - 'id' => $device->mac_address, - 'access-token' => $device->api_key, - 'rssi' => -70, - 'battery_voltage' => 3.8, - 'fw-version' => '1.0.0', - ])->get('/api/display'); - - $response->assertOk(); - - // Verify error screen was generated and set on device - $device->refresh(); - expect($device->current_screen_image)->not->toBeNull(); - - // Verify the error image exists - $errorImagePath = Storage::disk('public')->path("images/generated/{$device->current_screen_image}.png"); - // The TrmnlPipeline is faked, so we just verify the UUID was set - expect($device->current_screen_image)->toBeString(); -}); - -test('display endpoint handles mashup rendering errors gracefully', function (): void { - TrmnlPipeline::fake(); - - $device = Device::factory()->create([ - 'mac_address' => '00:11:22:33:44:55', - 'api_key' => 'test-api-key', - 'proxy_cloud' => false, - ]); - - // Create plugins for mashup, one with invalid markup - $plugin1 = Plugin::factory()->create([ - 'name' => 'Working Plugin', - 'data_strategy' => 'polling', - 'polling_url' => null, - 'data_stale_minutes' => 1, - 'render_markup_view' => 'trmnl', - 'data_payload_updated_at' => now()->subMinutes(2), - 'current_image' => null, - ]); - - $plugin2 = Plugin::factory()->create([ - 'name' => 'Broken Plugin', - 'data_strategy' => 'polling', - 'polling_url' => null, - 'data_stale_minutes' => 1, - 'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access - 'render_markup' => '
{{ $data[0]["invalid"] }}
', // This will fail - 'data_payload' => ['error' => 'Failed to fetch data'], - 'data_payload_updated_at' => now()->subMinutes(2), - 'current_image' => null, - ]); - - $playlist = Playlist::factory()->create([ - 'device_id' => $device->id, - 'name' => 'test_playlist', - 'is_active' => true, - 'weekdays' => null, - 'active_from' => null, - 'active_until' => null, - ]); - - // Create mashup playlist item - $playlistItem = PlaylistItem::createMashup( - $playlist, - '1Lx1R', - [$plugin1->id, $plugin2->id], - 'Test Mashup', - 1 - ); - - $response = $this->withHeaders([ - 'id' => $device->mac_address, - 'access-token' => $device->api_key, - 'rssi' => -70, - 'battery_voltage' => 3.8, - 'fw-version' => '1.0.0', - ])->get('/api/display'); - - $response->assertOk(); - - // Verify error screen was generated and set on device - $device->refresh(); - expect($device->current_screen_image)->not->toBeNull(); - - // Verify the error image UUID was set - expect($device->current_screen_image)->toBeString(); -}); - -test('generateDefaultScreenImage creates error screen with plugin name', function (): void { - TrmnlPipeline::fake(); - Storage::fake('public'); - Storage::disk('public')->makeDirectory('/images/generated'); - - $device = Device::factory()->create(); - - $errorUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', 'Test Recipe Name'); - - expect($errorUuid)->not->toBeEmpty(); - - // Verify the error image path would be created - $errorPath = "images/generated/{$errorUuid}.png"; - // Since TrmnlPipeline is faked, we just verify the UUID was generated - expect($errorUuid)->toBeString(); -}); - -test('generateDefaultScreenImage throws exception for invalid error image type', function (): void { - $device = Device::factory()->create(); - - expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-error-type')) - ->toThrow(InvalidArgumentException::class); -}); - -test('getDeviceSpecificDefaultImage returns null for error type when no device-specific image exists', function (): void { - $device = new Device(); - $device->deviceModel = null; - - $result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'error'); - expect($result)->toBeNull(); -}); diff --git a/tests/Feature/Api/ImageWebhookTest.php b/tests/Feature/Api/ImageWebhookTest.php deleted file mode 100644 index 121f90a..0000000 --- a/tests/Feature/Api/ImageWebhookTest.php +++ /dev/null @@ -1,196 +0,0 @@ -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/ImageGenerationServiceTest.php b/tests/Feature/ImageGenerationServiceTest.php index 07bb6a6..603205e 100644 --- a/tests/Feature/ImageGenerationServiceTest.php +++ b/tests/Feature/ImageGenerationServiceTest.php @@ -324,30 +324,6 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void expect($plugin->current_image)->toBe('test-uuid'); }); -it('cache is reset when plugin markup changes', function (): void { - // Create a plugin with cached image - $plugin = App\Models\Plugin::factory()->create([ - 'current_image' => 'cached-uuid', - 'render_markup' => '
Original markup
', - ]); - - // Create devices with standard dimensions (cacheable) - Device::factory()->count(2)->create([ - 'width' => 800, - 'height' => 480, - 'rotate' => 0, - ]); - - // Update the plugin markup - $plugin->update([ - 'render_markup' => '
Updated markup
', - ]); - - // Assert cache was reset when markup changed - $plugin->refresh(); - expect($plugin->current_image)->toBeNull(); -}); - it('determines correct image format from device model', function (): void { // Test BMP format detection $bmpModel = DeviceModel::factory()->create([ diff --git a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php index f0be135..7674d7f 100644 --- a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php +++ b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php @@ -44,7 +44,6 @@ 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', ], ], @@ -75,7 +74,6 @@ 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'); }); @@ -314,7 +312,6 @@ 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/Catalog/IndexTest.php b/tests/Feature/Livewire/Catalog/IndexTest.php index 1b2efba..22ab4b6 100644 --- a/tests/Feature/Livewire/Catalog/IndexTest.php +++ b/tests/Feature/Livewire/Catalog/IndexTest.php @@ -65,46 +65,6 @@ it('loads plugins from catalog URL', function (): void { $component->assertSee('testuser'); $component->assertSee('A test plugin'); $component->assertSee('MIT'); - $component->assertSee('Preview'); -}); - -it('hides preview button when screenshot_url is missing', function (): void { - // Clear cache first to ensure fresh data - Cache::forget('catalog_plugins'); - - // Mock the HTTP response for the catalog URL without screenshot_url - $catalogData = [ - 'test-plugin' => [ - 'name' => 'Test Plugin Without Screenshot', - 'author' => ['name' => 'Test Author', 'github' => 'testuser'], - 'author_bio' => [ - 'description' => 'A test plugin', - ], - 'license' => 'MIT', - 'trmnlp' => [ - 'zip_url' => 'https://example.com/plugin.zip', - ], - 'byos' => [ - 'byos_laravel' => [ - 'compatibility' => true, - ], - ], - 'logo_url' => 'https://example.com/logo.png', - 'screenshot_url' => null, - ], - ]; - - $yamlContent = Yaml::dump($catalogData); - - Http::fake([ - config('app.catalog_url') => Http::response($yamlContent, 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.index') - ->assertSee('Test Plugin Without Screenshot') - ->assertDontSeeHtml('variant="subtle" icon="eye"'); }); it('shows error when plugin not found', function (): void { @@ -154,46 +114,3 @@ it('shows error when zip_url is missing', function (): void { $component->assertHasErrors(); }); - -it('can preview a plugin', function (): void { - // Clear cache first to ensure fresh data - Cache::forget('catalog_plugins'); - - // Mock the HTTP response for the catalog URL - $catalogData = [ - 'test-plugin' => [ - 'name' => 'Test Plugin', - 'author' => ['name' => 'Test Author', 'github' => 'testuser'], - 'author_bio' => [ - 'description' => 'A test plugin description', - ], - 'license' => 'MIT', - 'trmnlp' => [ - 'zip_url' => 'https://example.com/plugin.zip', - ], - 'byos' => [ - 'byos_laravel' => [ - 'compatibility' => true, - ], - ], - 'logo_url' => 'https://example.com/logo.png', - 'screenshot_url' => 'https://example.com/screenshot.png', - ], - ]; - - $yamlContent = Yaml::dump($catalogData); - - Http::fake([ - config('app.catalog_url') => Http::response($yamlContent, 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.index') - ->assertSee('Test Plugin') - ->call('previewPlugin', 'test-plugin') - ->assertSet('previewingPlugin', 'test-plugin') - ->assertSet('previewData.name', 'Test Plugin') - ->assertSee('Preview Test Plugin') - ->assertSee('A test plugin description'); -}); diff --git a/tests/Feature/Livewire/Plugins/ConfigModalTest.php b/tests/Feature/Livewire/Plugins/ConfigModalTest.php deleted file mode 100644 index 4372991..0000000 --- a/tests/Feature/Livewire/Plugins/ConfigModalTest.php +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index a04815f..0000000 --- a/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php +++ /dev/null @@ -1,112 +0,0 @@ -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/PlaylistSchedulingTest.php b/tests/Feature/PlaylistSchedulingTest.php index 18d0032..aea4923 100644 --- a/tests/Feature/PlaylistSchedulingTest.php +++ b/tests/Feature/PlaylistSchedulingTest.php @@ -130,48 +130,3 @@ test('playlist isActiveNow handles normal time ranges correctly', function (): v Carbon::setTestNow(Carbon::create(2024, 1, 1, 20, 0, 0)); expect($playlist->isActiveNow())->toBeFalse(); }); - -test('playlist scheduling respects user timezone preference', function (): void { - // Create a user with a timezone that's UTC+1 (e.g., Europe/Berlin) - // This simulates the bug where setting 00:15 doesn't work until one hour later - $user = User::factory()->create([ - 'timezone' => 'Europe/Berlin', // UTC+1 in winter, UTC+2 in summer - ]); - - $device = Device::factory()->create(['user_id' => $user->id]); - - // Create a playlist that should be active from 00:15 to 01:00 in the user's timezone - $playlist = Playlist::factory()->create([ - 'device_id' => $device->id, - 'is_active' => true, - 'active_from' => '00:15', - 'active_until' => '01:00', - 'weekdays' => null, - ]); - - // Set test time to 00:15 in the user's timezone (Europe/Berlin) - // In January, Europe/Berlin is UTC+1, so 00:15 Berlin time = 23:15 UTC the previous day - // But Carbon::setTestNow uses UTC by default, so we need to set it to the UTC equivalent - // For January 1, 2024 at 00:15 Berlin time (UTC+1), that's December 31, 2023 at 23:15 UTC - $berlinTime = Carbon::create(2024, 1, 1, 0, 15, 0, 'Europe/Berlin'); - Carbon::setTestNow($berlinTime->utc()); - - // The playlist should be active at 00:15 in the user's timezone - // This test should pass after the fix, but will fail with the current bug - expect($playlist->isActiveNow())->toBeTrue(); - - // Test at 00:30 in user's timezone - should still be active - $berlinTime = Carbon::create(2024, 1, 1, 0, 30, 0, 'Europe/Berlin'); - Carbon::setTestNow($berlinTime->utc()); - expect($playlist->isActiveNow())->toBeTrue(); - - // Test at 01:15 in user's timezone - should NOT be active (past the end time) - $berlinTime = Carbon::create(2024, 1, 1, 1, 15, 0, 'Europe/Berlin'); - Carbon::setTestNow($berlinTime->utc()); - expect($playlist->isActiveNow())->toBeFalse(); - - // Test at 00:10 in user's timezone - should NOT be active (before start time) - $berlinTime = Carbon::create(2024, 1, 1, 0, 10, 0, 'Europe/Berlin'); - Carbon::setTestNow($berlinTime->utc()); - expect($playlist->isActiveNow())->toBeFalse(); -}); diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php index f3ef1fa..1b20f93 100644 --- a/tests/Feature/PluginImportTest.php +++ b/tests/Feature/PluginImportTest.php @@ -83,34 +83,19 @@ it('throws exception for invalid zip file', function (): void { ->toThrow(Exception::class, 'Could not open the ZIP file.'); }); -it('throws exception for missing settings.yml', function (): void { - $user = User::factory()->create(); - - $zipContent = createMockZipFile([ - '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 file settings.yml is missing.'); -}); - -it('throws exception for missing template files', function (): void { +it('throws exception for missing required files', function (): void { $user = User::factory()->create(); $zipContent = createMockZipFile([ 'src/settings.yml' => getValidSettingsYaml(), - // Missing all template files + // Missing full.liquid ]); $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.'); + ->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); }); it('sets default values when settings are missing', function (): void { @@ -442,103 +427,6 @@ 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/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php index a80c63a..ba1b722 100644 --- a/tests/Feature/Volt/CatalogTrmnlTest.php +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -28,33 +28,9 @@ it('loads newest TRMNL recipes on mount', function (): void { Volt::test('catalog.trmnl') ->assertSee('Weather Chum') ->assertSee('Install') - ->assertDontSeeHtml('variant="subtle" icon="eye"') ->assertSee('Installs: 10'); }); -it('shows preview button when screenshot_url is provided', function (): void { - Http::fake([ - 'usetrmnl.com/recipes.json*' => Http::response([ - 'data' => [ - [ - 'id' => 123, - 'name' => 'Weather Chum', - 'icon_url' => 'https://example.com/icon.png', - 'screenshot_url' => 'https://example.com/screenshot.png', - 'author_bio' => null, - 'stats' => ['installs' => 10, 'forks' => 2], - ], - ], - ], 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Weather Chum') - ->assertSee('Preview'); -}); - it('searches TRMNL recipes when search term is provided', function (): void { Http::fake([ // First call (mount -> newest) @@ -176,111 +152,3 @@ it('shows error when plugin installation fails', function (): void { ->call('installPlugin', '123') ->assertSee('Error installing plugin'); // This will fail because the zip content is invalid }); - -it('previews a recipe with async fetch', function (): void { - Http::fake([ - 'usetrmnl.com/recipes.json*' => Http::response([ - 'data' => [ - [ - 'id' => 123, - 'name' => 'Weather Chum', - 'icon_url' => 'https://example.com/icon.png', - 'screenshot_url' => 'https://example.com/old.png', - 'author_bio' => null, - 'stats' => ['installs' => 10, 'forks' => 2], - ], - ], - ], 200), - 'usetrmnl.com/recipes/123.json' => Http::response([ - 'data' => [ - 'id' => 123, - 'name' => 'Weather Chum Updated', - 'icon_url' => 'https://example.com/icon.png', - 'screenshot_url' => 'https://example.com/new.png', - 'author_bio' => ['description' => 'New bio'], - 'stats' => ['installs' => 11, 'forks' => 3], - ], - ], 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Weather Chum') - ->call('previewRecipe', '123') - ->assertSet('previewingRecipe', '123') - ->assertSet('previewData.name', 'Weather Chum Updated') - ->assertSet('previewData.screenshot_url', 'https://example.com/new.png') - ->assertSee('Preview Weather Chum Updated') - ->assertSee('New bio'); -}); - -it('supports pagination and loading more recipes', function (): void { - Http::fake([ - 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([ - 'data' => [ - [ - 'id' => 1, - 'name' => 'Recipe Page 1', - 'icon_url' => null, - 'screenshot_url' => null, - 'author_bio' => null, - 'stats' => ['installs' => 1, 'forks' => 0], - ], - ], - 'next_page_url' => '/recipes.json?page=2', - ], 200), - 'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([ - 'data' => [ - [ - 'id' => 2, - 'name' => 'Recipe Page 2', - 'icon_url' => null, - 'screenshot_url' => null, - 'author_bio' => null, - 'stats' => ['installs' => 2, 'forks' => 0], - ], - ], - 'next_page_url' => null, - ], 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Recipe Page 1') - ->assertDontSee('Recipe Page 2') - ->assertSee('Load next page') - ->call('loadMore') - ->assertSee('Recipe Page 1') - ->assertSee('Recipe Page 2') - ->assertDontSee('Load next page'); -}); - -it('resets pagination when search term changes', function (): void { - Http::fake([ - 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence() - ->push([ - 'data' => [['id' => 1, 'name' => 'Initial 1']], - 'next_page_url' => '/recipes.json?page=2', - ]) - ->push([ - 'data' => [['id' => 3, 'name' => 'Initial 1 Again']], - 'next_page_url' => null, - ]), - 'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([ - 'data' => [['id' => 2, 'name' => 'Weather Result']], - 'next_page_url' => null, - ]), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Initial 1') - ->call('loadMore') - ->set('search', 'weather') - ->assertSee('Weather Result') - ->assertDontSee('Initial 1') - ->assertSet('page', 1); -}); diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index aa9a28e..cf8ea97 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -99,35 +99,6 @@ 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', @@ -708,233 +679,3 @@ 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 ', '