diff --git a/.gitignore b/.gitignore index 02f3d78..0eb46d3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,11 @@ 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/app/Jobs/FetchDeviceModelsJob.php b/app/Jobs/FetchDeviceModelsJob.php index cb24d98..475c5c7 100644 --- a/app/Jobs/FetchDeviceModelsJob.php +++ b/app/Jobs/FetchDeviceModelsJob.php @@ -199,6 +199,7 @@ final class FetchDeviceModelsJob implements ShouldQueue 'offset_x' => $modelData['offset_x'] ?? 0, 'offset_y' => $modelData['offset_y'] ?? 0, 'published_at' => $modelData['published_at'] ?? null, + 'kind' => $modelData['kind'] ?? null, 'source' => 'api', ]; diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index c4b45c8..68f8e7e 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -11,7 +11,6 @@ use App\Liquid\Filters\StandardFilters; use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\Uniqueness; use App\Liquid\Tags\TemplateTag; -use App\Services\ImageGenerationService; use App\Services\Plugin\Parsers\ResponseParserRegistry; use App\Services\PluginImportService; use Carbon\Carbon; @@ -25,6 +24,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; +use InvalidArgumentException; use Keepsuit\LaravelLiquid\LaravelLiquidExtension; use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Extensions\StandardExtension; @@ -46,6 +46,7 @@ class Plugin extends Model 'dark_mode' => 'boolean', 'preferred_renderer' => 'string', 'plugin_type' => 'string', + 'alias' => 'boolean', ]; protected static function boot() @@ -153,105 +154,67 @@ class Plugin extends Model public function updateDataPayload(): void { - if ($this->data_strategy === 'polling' && $this->polling_url) { - - $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; - - if ($this->polling_header) { - // Resolve Liquid variables in the polling header - $resolvedHeader = $this->resolveLiquidVariables($this->polling_header); - $headerLines = explode("\n", mb_trim($resolvedHeader)); - foreach ($headerLines as $line) { - $parts = explode(':', $line, 2); - if (count($parts) === 2) { - $headers[mb_trim($parts[0])] = mb_trim($parts[1]); - } - } - } - - // Resolve Liquid variables in the entire polling_url field first, then split by newline - $resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url); - $urls = array_filter( - array_map('trim', explode("\n", $resolvedPollingUrls)), - fn ($url): bool => ! empty($url) - ); - - // If only one URL, use the original logic without nesting - if (count($urls) === 1) { - $url = reset($urls); - $httpRequest = Http::withHeaders($headers); - - if ($this->polling_verb === 'post' && $this->polling_body) { - // Resolve Liquid variables in the polling body - $resolvedBody = $this->resolveLiquidVariables($this->polling_body); - $httpRequest = $httpRequest->withBody($resolvedBody); - } - - // URL is already resolved, use it directly - $resolvedUrl = $url; - - try { - // Make the request based on the verb - $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl); - - $response = $this->parseResponse($httpResponse); - - $this->update([ - 'data_payload' => $response, - 'data_payload_updated_at' => now(), - ]); - } catch (Exception $e) { - Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage()); - $this->update([ - 'data_payload' => ['error' => 'Failed to fetch data'], - 'data_payload_updated_at' => now(), - ]); - } - - return; - } - - // Multiple URLs - use nested response logic - $combinedResponse = []; - - foreach ($urls as $index => $url) { - $httpRequest = Http::withHeaders($headers); - - if ($this->polling_verb === 'post' && $this->polling_body) { - // Resolve Liquid variables in the polling body - $resolvedBody = $this->resolveLiquidVariables($this->polling_body); - $httpRequest = $httpRequest->withBody($resolvedBody); - } - - // URL is already resolved, use it directly - $resolvedUrl = $url; - - try { - // Make the request based on the verb - $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl); - - $response = $this->parseResponse($httpResponse); - - // Check if response is an array at root level - if (array_keys($response) === range(0, count($response) - 1)) { - // Response is a sequential array, nest under .data - $combinedResponse["IDX_{$index}"] = ['data' => $response]; - } else { - // Response is an object or associative array, keep as is - $combinedResponse["IDX_{$index}"] = $response; - } - } catch (Exception $e) { - // Log error and continue with other URLs - Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage()); - $combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data']; - } - } - - $this->update([ - 'data_payload' => $combinedResponse, - 'data_payload_updated_at' => now(), - ]); + if ($this->data_strategy !== 'polling' || ! $this->polling_url) { + return; } + $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; + + // resolve headers + if ($this->polling_header) { + $resolvedHeader = $this->resolveLiquidVariables($this->polling_header); + $headerLines = explode("\n", mb_trim($resolvedHeader)); + foreach ($headerLines as $line) { + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $headers[mb_trim($parts[0])] = mb_trim($parts[1]); + } + } + } + + // resolve and clean URLs + $resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url); + $urls = array_values(array_filter( // array_values ensures 0, 1, 2... + array_map('trim', explode("\n", $resolvedPollingUrls)), + fn ($url): bool => filled($url) + )); + + $combinedResponse = []; + + // Loop through all URLs (Handles 1 or many) + foreach ($urls as $index => $url) { + $httpRequest = Http::withHeaders($headers); + + if ($this->polling_verb === 'post' && $this->polling_body) { + $resolvedBody = $this->resolveLiquidVariables($this->polling_body); + $httpRequest = $httpRequest->withBody($resolvedBody); + } + + try { + $httpResponse = ($this->polling_verb === 'post') + ? $httpRequest->post($url) + : $httpRequest->get($url); + + $response = $this->parseResponse($httpResponse); + + // Nest if it's a sequential array + if (array_keys($response) === range(0, count($response) - 1)) { + $combinedResponse["IDX_{$index}"] = ['data' => $response]; + } else { + $combinedResponse["IDX_{$index}"] = $response; + } + } catch (Exception $e) { + Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage()); + $combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data']; + } + } + + // unwrap IDX_0 if only one URL + $finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse; + + $this->update([ + 'data_payload' => $finalPayload, + 'data_payload_updated_at' => now(), + ]); } private function parseResponse(Response $httpResponse): array @@ -455,7 +418,7 @@ 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.'); + throw new InvalidArgumentException('Render method is only applicable for recipe plugins.'); } if ($this->render_markup) { @@ -565,17 +528,30 @@ class Plugin extends Model if ($this->render_markup_view) { if ($standalone) { - return view('trmnl-layouts.single', [ + $renderedView = view($this->render_markup_view, [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ])->render(); + + if ($size === 'full') { + return view('trmnl-layouts.single', [ + 'colorDepth' => $device?->colorDepth(), + 'deviceVariant' => $device?->deviceVariant() ?? 'og', + 'noBleed' => $this->no_bleed, + 'darkMode' => $this->dark_mode, + 'scaleLevel' => $device?->scaleLevel(), + 'slot' => $renderedView, + ])->render(); + } + + return view('trmnl-layouts.mashup', [ + 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size), 'colorDepth' => $device?->colorDepth(), 'deviceVariant' => $device?->deviceVariant() ?? 'og', - 'noBleed' => $this->no_bleed, 'darkMode' => $this->dark_mode, 'scaleLevel' => $device?->scaleLevel(), - 'slot' => view($this->render_markup_view, [ - 'size' => $size, - 'data' => $this->data_payload, - 'config' => $this->configuration ?? [], - ])->render(), + 'slot' => $renderedView, ])->render(); } @@ -606,4 +582,61 @@ class Plugin extends Model default => '1Tx1B', }; } + + /** + * Duplicate the plugin, copying all attributes and handling render_markup_view + * + * @param int|null $userId Optional user ID for the duplicate. If not provided, uses the original plugin's user_id. + * @return Plugin The newly created duplicate plugin + */ + public function duplicate(?int $userId = null): self + { + // Get all attributes except id and uuid + // Use toArray() to get cast values (respects JSON casts) + $attributes = $this->toArray(); + unset($attributes['id'], $attributes['uuid']); + + // Handle render_markup_view - copy file content to render_markup + if ($this->render_markup_view) { + try { + $basePath = resource_path('views/'.str_replace('.', '/', $this->render_markup_view)); + $paths = [ + $basePath.'.blade.php', + $basePath.'.liquid', + ]; + + $fileContent = null; + $markupLanguage = null; + foreach ($paths as $path) { + if (file_exists($path)) { + $fileContent = file_get_contents($path); + // Determine markup language based on file extension + $markupLanguage = str_ends_with($path, '.liquid') ? 'liquid' : 'blade'; + break; + } + } + + if ($fileContent !== null) { + $attributes['render_markup'] = $fileContent; + $attributes['markup_language'] = $markupLanguage; + $attributes['render_markup_view'] = null; + } else { + // File doesn't exist, remove the view reference + $attributes['render_markup_view'] = null; + } + } catch (Exception $e) { + // If file reading fails, remove the view reference + $attributes['render_markup_view'] = null; + } + } + + // Append " (Copy)" to the name + $attributes['name'] = $this->name.' (Copy)'; + + // Set user_id - use provided userId or fall back to original plugin's user_id + $attributes['user_id'] = $userId ?? $this->user_id; + + // Create and return the new plugin + return self::create($attributes); + } } diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index b8269a3..405ea3f 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -26,11 +26,44 @@ class ImageGenerationService public static function generateImage(string $markup, $deviceId): string { $device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId); + $uuid = self::generateImageFromModel( + markup: $markup, + deviceModel: $device->deviceModel, + user: $device->user, + palette: $device->palette ?? $device->deviceModel?->palette, + device: $device + ); + + $device->update(['current_screen_image' => $uuid]); + Log::info("Device $device->id: updated with new image: $uuid"); + + return $uuid; + } + + /** + * Generate an image from markup using a DeviceModel + * + * @param string $markup The HTML markup to render + * @param DeviceModel|null $deviceModel The device model to use for image generation + * @param \App\Models\User|null $user Optional user for timezone settings + * @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette + * @param Device|null $device Optional device for legacy devices without DeviceModel + * @return string The UUID of the generated image + */ + public static function generateImageFromModel( + string $markup, + ?DeviceModel $deviceModel = null, + ?\App\Models\User $user = null, + ?\App\Models\DevicePalette $palette = null, + ?Device $device = null + ): string { $uuid = Uuid::uuid4()->toString(); try { - // Get image generation settings from DeviceModel if available, otherwise use device settings - $imageSettings = self::getImageSettings($device); + // Get image generation settings from DeviceModel or Device (for legacy devices) + $imageSettings = $deviceModel + ? self::getImageSettingsFromModel($deviceModel) + : ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null)); $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png'; $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); @@ -45,7 +78,7 @@ class ImageGenerationService $browserStage->html($markup); // Set timezone from user or fall back to app timezone - $timezone = $device->user->timezone ?? config('app.timezone'); + $timezone = $user?->timezone ?? config('app.timezone'); $browserStage->timezone($timezone); if (config('app.puppeteer_window_size_strategy') === 'v2') { @@ -65,12 +98,12 @@ class ImageGenerationService $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']); } - // Get palette from device or fallback to device model's default palette - $palette = $device->palette ?? $device->deviceModel?->palette; + // Get palette from parameter or fallback to device model's default palette $colorPalette = null; - if ($palette && $palette->colors) { $colorPalette = $palette->colors; + } elseif ($deviceModel?->palette && $deviceModel->palette->colors) { + $colorPalette = $deviceModel->palette->colors; } $imageStage = new ImageStage(); @@ -107,8 +140,7 @@ class ImageGenerationService throw new RuntimeException('Image file is empty: '.$outputPath); } - $device->update(['current_screen_image' => $uuid]); - Log::info("Device $device->id: updated with new image: $uuid"); + Log::info("Generated image: $uuid"); return $uuid; @@ -125,22 +157,7 @@ class ImageGenerationService { // If device has a DeviceModel, use its settings if ($device->deviceModel) { - /** @var DeviceModel $model */ - $model = $device->deviceModel; - - return [ - 'width' => $model->width, - 'height' => $model->height, - 'colors' => $model->colors, - 'bit_depth' => $model->bit_depth, - 'scale_factor' => $model->scale_factor, - 'rotation' => $model->rotation, - 'mime_type' => $model->mime_type, - 'offset_x' => $model->offset_x, - 'offset_y' => $model->offset_y, - 'image_format' => self::determineImageFormatFromModel($model), - 'use_model_settings' => true, - ]; + return self::getImageSettingsFromModel($device->deviceModel); } // Fallback to device settings @@ -164,6 +181,43 @@ class ImageGenerationService ]; } + /** + * Get image generation settings from a DeviceModel + */ + private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array + { + if ($deviceModel) { + return [ + 'width' => $deviceModel->width, + 'height' => $deviceModel->height, + 'colors' => $deviceModel->colors, + 'bit_depth' => $deviceModel->bit_depth, + 'scale_factor' => $deviceModel->scale_factor, + 'rotation' => $deviceModel->rotation, + 'mime_type' => $deviceModel->mime_type, + 'offset_x' => $deviceModel->offset_x, + 'offset_y' => $deviceModel->offset_y, + 'image_format' => self::determineImageFormatFromModel($deviceModel), + 'use_model_settings' => true, + ]; + } + + // Default settings if no device model provided + return [ + 'width' => 800, + 'height' => 480, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'image_format' => ImageFormat::AUTO->value, + 'use_model_settings' => false, + ]; + } + /** * Determine the appropriate ImageFormat based on DeviceModel settings */ diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index 9207e3e..49dce99 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -17,6 +17,34 @@ use ZipArchive; class PluginImportService { + /** + * Validate YAML settings + * + * @param array $settings The parsed YAML settings + * + * @throws Exception + */ + private function validateYAML(array $settings): void + { + if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) { + return; + } + + foreach ($settings['custom_fields'] as $field) { + if (isset($field['field_type']) && $field['field_type'] === 'multi_string') { + + if (isset($field['default']) && str_contains($field['default'], ',')) { + throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas."); + } + + if (isset($field['placeholder']) && str_contains($field['placeholder'], ',')) { + throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas."); + } + + } + } + } + /** * Import a plugin from a ZIP file * @@ -47,32 +75,55 @@ class PluginImportService $zip->extractTo($tempDir); $zip->close(); - // Find the required files (settings.yml and full.liquid/full.blade.php) + // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php) $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); // Validate that we found the required files - if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { - throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php + if (! $filePaths['settingsYamlPath']) { + throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.'); + } + + // Validate that we have at least one template file + if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) { + throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.'); } // Parse settings.yml $settingsYaml = File::get($filePaths['settingsYamlPath']); $settings = Yaml::parse($settingsYaml); + $this->validateYAML($settings); - // Read full.liquid content - $fullLiquid = File::get($filePaths['fullLiquidPath']); - - // Prepend shared.liquid content if available - if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { - $sharedLiquid = File::get($filePaths['sharedLiquidPath']); - $fullLiquid = $sharedLiquid."\n".$fullLiquid; - } - - // Check if the file ends with .liquid to set markup language + // Determine which template file to use and read its content + $templatePath = null; $markupLanguage = 'blade'; - if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') { + + if ($filePaths['fullLiquidPath']) { + $templatePath = $filePaths['fullLiquidPath']; + $fullLiquid = File::get($templatePath); + + // Prepend shared.liquid or shared.blade.php content if available + if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { + $sharedLiquid = File::get($filePaths['sharedLiquidPath']); + $fullLiquid = $sharedLiquid."\n".$fullLiquid; + } elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) { + $sharedBlade = File::get($filePaths['sharedBladePath']); + $fullLiquid = $sharedBlade."\n".$fullLiquid; + } + + // Check if the file ends with .liquid to set markup language + if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') { + $markupLanguage = 'liquid'; + $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + } + } elseif ($filePaths['sharedLiquidPath']) { + $templatePath = $filePaths['sharedLiquidPath']; + $fullLiquid = File::get($templatePath); $markupLanguage = 'liquid'; $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + } elseif ($filePaths['sharedBladePath']) { + $templatePath = $filePaths['sharedBladePath']; + $fullLiquid = File::get($templatePath); + $markupLanguage = 'blade'; } // Ensure custom_fields is properly formatted @@ -144,11 +195,12 @@ class PluginImportService * @param string|null $zipEntryPath Optional path to specific plugin in monorepo * @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid') * @param string|null $iconUrl Optional icon URL to set on the plugin + * @param bool $allowDuplicate If true, generate a new UUID for trmnlp_id if a plugin with the same trmnlp_id already exists * @return Plugin The created plugin instance * * @throws Exception If the ZIP file is invalid or required files are missing */ - public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin + public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin { // Download the ZIP file $response = Http::timeout(60)->get($zipUrl); @@ -176,32 +228,55 @@ class PluginImportService $zip->extractTo($tempDir); $zip->close(); - // Find the required files (settings.yml and full.liquid/full.blade.php) + // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php) $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); // Validate that we found the required files - if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { - throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.'); + if (! $filePaths['settingsYamlPath']) { + throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.'); + } + + // Validate that we have at least one template file + if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) { + throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.'); } // Parse settings.yml $settingsYaml = File::get($filePaths['settingsYamlPath']); $settings = Yaml::parse($settingsYaml); + $this->validateYAML($settings); - // Read full.liquid content - $fullLiquid = File::get($filePaths['fullLiquidPath']); - - // Prepend shared.liquid content if available - if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { - $sharedLiquid = File::get($filePaths['sharedLiquidPath']); - $fullLiquid = $sharedLiquid."\n".$fullLiquid; - } - - // Check if the file ends with .liquid to set markup language + // Determine which template file to use and read its content + $templatePath = null; $markupLanguage = 'blade'; - if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') { + + if ($filePaths['fullLiquidPath']) { + $templatePath = $filePaths['fullLiquidPath']; + $fullLiquid = File::get($templatePath); + + // Prepend shared.liquid or shared.blade.php content if available + if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { + $sharedLiquid = File::get($filePaths['sharedLiquidPath']); + $fullLiquid = $sharedLiquid."\n".$fullLiquid; + } elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) { + $sharedBlade = File::get($filePaths['sharedBladePath']); + $fullLiquid = $sharedBlade."\n".$fullLiquid; + } + + // Check if the file ends with .liquid to set markup language + if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') { + $markupLanguage = 'liquid'; + $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + } + } elseif ($filePaths['sharedLiquidPath']) { + $templatePath = $filePaths['sharedLiquidPath']; + $fullLiquid = File::get($templatePath); $markupLanguage = 'liquid'; $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + } elseif ($filePaths['sharedBladePath']) { + $templatePath = $filePaths['sharedBladePath']; + $fullLiquid = File::get($templatePath); + $markupLanguage = 'blade'; } // Ensure custom_fields is properly formatted @@ -217,17 +292,26 @@ class PluginImportService 'custom_fields' => $settings['custom_fields'], ]; - $plugin_updated = isset($settings['id']) + // Determine the trmnlp_id to use + $trmnlpId = $settings['id'] ?? Uuid::v7(); + + // If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID + if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) { + $trmnlpId = Uuid::v7(); + } + + $plugin_updated = ! $allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists(); + // Create a new plugin $plugin = Plugin::updateOrCreate( [ - 'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + 'user_id' => $user->id, 'trmnlp_id' => $trmnlpId, ], [ 'user_id' => $user->id, 'name' => $settings['name'] ?? 'Imported Plugin', - 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + 'trmnlp_id' => $trmnlpId, 'data_stale_minutes' => $settings['refresh_interval'] ?? 15, 'data_strategy' => $settings['strategy'] ?? 'static', 'polling_url' => $settings['polling_url'] ?? null, @@ -272,6 +356,7 @@ class PluginImportService $settingsYamlPath = null; $fullLiquidPath = null; $sharedLiquidPath = null; + $sharedBladePath = null; // If zipEntryPath is specified, look for files in that specific directory first if ($zipEntryPath) { @@ -289,6 +374,8 @@ class PluginImportService if (File::exists($targetDir.'/shared.liquid')) { $sharedLiquidPath = $targetDir.'/shared.liquid'; + } elseif (File::exists($targetDir.'/shared.blade.php')) { + $sharedBladePath = $targetDir.'/shared.blade.php'; } } @@ -304,15 +391,18 @@ class PluginImportService if (File::exists($targetDir.'/src/shared.liquid')) { $sharedLiquidPath = $targetDir.'/src/shared.liquid'; + } elseif (File::exists($targetDir.'/src/shared.blade.php')) { + $sharedBladePath = $targetDir.'/src/shared.blade.php'; } } // If we found the required files in the target directory, return them - if ($settingsYamlPath && $fullLiquidPath) { + if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) { return [ 'settingsYamlPath' => $settingsYamlPath, 'fullLiquidPath' => $fullLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath, + 'sharedBladePath' => $sharedBladePath, ]; } } @@ -329,9 +419,11 @@ class PluginImportService $fullLiquidPath = $tempDir.'/src/full.blade.php'; } - // Check for shared.liquid in the same directory + // Check for shared.liquid or shared.blade.php in the same directory if (File::exists($tempDir.'/src/shared.liquid')) { $sharedLiquidPath = $tempDir.'/src/shared.liquid'; + } elseif (File::exists($tempDir.'/src/shared.blade.php')) { + $sharedBladePath = $tempDir.'/src/shared.blade.php'; } } else { // Search for the files in the extracted directory structure @@ -348,20 +440,24 @@ class PluginImportService $fullLiquidPath = $filepath; } elseif ($filename === 'shared.liquid') { $sharedLiquidPath = $filepath; + } elseif ($filename === 'shared.blade.php') { + $sharedBladePath = $filepath; } } - // Check if shared.liquid exists in the same directory as full.liquid - if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath) { + // Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid + if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) { $fullLiquidDir = dirname((string) $fullLiquidPath); if (File::exists($fullLiquidDir.'/shared.liquid')) { $sharedLiquidPath = $fullLiquidDir.'/shared.liquid'; + } elseif (File::exists($fullLiquidDir.'/shared.blade.php')) { + $sharedBladePath = $fullLiquidDir.'/shared.blade.php'; } } // If we found the files but they're not in the src folder, // check if they're in the root of the ZIP or in a subfolder - if ($settingsYamlPath && $fullLiquidPath) { + if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) { // If the files are in the root of the ZIP, create a src folder and move them there $srcDir = dirname((string) $settingsYamlPath); @@ -372,17 +468,25 @@ class PluginImportService // Copy the files to the src directory File::copy($settingsYamlPath, $newSrcDir.'/settings.yml'); - File::copy($fullLiquidPath, $newSrcDir.'/full.liquid'); - // Copy shared.liquid if it exists + // Copy full.liquid or full.blade.php if it exists + if ($fullLiquidPath) { + $extension = pathinfo((string) $fullLiquidPath, PATHINFO_EXTENSION); + File::copy($fullLiquidPath, $newSrcDir.'/full.'.$extension); + $fullLiquidPath = $newSrcDir.'/full.'.$extension; + } + + // Copy shared.liquid or shared.blade.php if it exists if ($sharedLiquidPath) { File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid'); $sharedLiquidPath = $newSrcDir.'/shared.liquid'; + } elseif ($sharedBladePath) { + File::copy($sharedBladePath, $newSrcDir.'/shared.blade.php'); + $sharedBladePath = $newSrcDir.'/shared.blade.php'; } // Update the paths $settingsYamlPath = $newSrcDir.'/settings.yml'; - $fullLiquidPath = $newSrcDir.'/full.liquid'; } } } @@ -391,6 +495,7 @@ class PluginImportService 'settingsYamlPath' => $settingsYamlPath, 'fullLiquidPath' => $fullLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath, + 'sharedBladePath' => $sharedBladePath, ]; } diff --git a/boost.json b/boost.json deleted file mode 100644 index 53962fa..0000000 --- a/boost.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "agents": [ - "claude_code", - "copilot", - "cursor", - "phpstorm" - ], - "editors": [ - "claude_code", - "cursor", - "phpstorm", - "vscode" - ], - "guidelines": [] -} diff --git a/composer.json b/composer.json index 0ced4da..8903e17 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,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", diff --git a/composer.lock b/composer.lock index 4d72e3c..a469e55 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": "25c2a1a4a2f2594adefe25ddb6a072fb", + "content-hash": "4de5f1df0160f59d08f428e36e81262e", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.6", + "version": "3.369.12", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "b1e1846a4b6593b6916764d86fc0890a31727370" + "reference": "36ee8894743a254ae2650bad4968c874b76bc7de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b1e1846a4b6593b6916764d86fc0890a31727370", - "reference": "b1e1846a4b6593b6916764d86fc0890a31727370", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/36ee8894743a254ae2650bad4968c874b76bc7de", + "reference": "36ee8894743a254ae2650bad4968c874b76bc7de", "shasum": "" }, "require": { @@ -153,9 +153,63 @@ "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.6" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.12" }, - "time": "2026-01-02T19:09:23+00:00" + "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" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -441,6 +495,56 @@ ], "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", @@ -877,16 +981,16 @@ }, { "name": "firebase/php-jwt", - "version": "v6.11.1", + "version": "v7.0.2", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", "shasum": "" }, "require": { @@ -934,9 +1038,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" }, - "time": "2025-04-09T20:32:01+00:00" + "time": "2025-12-16T22:17:28+00:00" }, { "name": "fruitcake/php-cors", @@ -1678,16 +1782,16 @@ }, { "name": "laravel/framework", - "version": "v12.44.0", + "version": "v12.47.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "592bbf1c036042958332eb98e3e8131b29102f33" + "reference": "ab8114c2e78f32e64eb238fc4b495bea3f8b80ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/592bbf1c036042958332eb98e3e8131b29102f33", - "reference": "592bbf1c036042958332eb98e3e8131b29102f33", + "url": "https://api.github.com/repos/laravel/framework/zipball/ab8114c2e78f32e64eb238fc4b495bea3f8b80ec", + "reference": "ab8114c2e78f32e64eb238fc4b495bea3f8b80ec", "shasum": "" }, "require": { @@ -1896,20 +2000,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-23T15:29:43+00:00" + "time": "2026-01-13T15:29:06+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.8", + "version": "v0.3.9", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "096748cdfb81988f60090bbb839ce3205ace0d35" + "reference": "5c41bf0555b7cfefaad4e66d3046675829581ac4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/096748cdfb81988f60090bbb839ce3205ace0d35", - "reference": "096748cdfb81988f60090bbb839ce3205ace0d35", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5c41bf0555b7cfefaad4e66d3046675829581ac4", + "reference": "5c41bf0555b7cfefaad4e66d3046675829581ac4", "shasum": "" }, "require": { @@ -1953,22 +2057,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.8" + "source": "https://github.com/laravel/prompts/tree/v0.3.9" }, - "time": "2025-11-21T20:52:52+00:00" + "time": "2026-01-07T21:00:29+00:00" }, { "name": "laravel/sanctum", - "version": "v4.2.1", + "version": "v4.2.3", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664" + "reference": "47d26f1d310879ff757b971f5a6fc631d18663fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/f5fb373be39a246c74a060f2cf2ae2c2145b3664", - "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/47d26f1d310879ff757b971f5a6fc631d18663fd", + "reference": "47d26f1d310879ff757b971f5a6fc631d18663fd", "shasum": "" }, "require": { @@ -2018,20 +2122,20 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2025-11-21T13:59:03+00:00" + "time": "2026-01-11T18:20:25+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.7", + "version": "v2.0.8", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" + "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", - "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", + "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", "shasum": "" }, "require": { @@ -2079,25 +2183,25 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-11-21T20:52:36+00:00" + "time": "2026-01-08T16:22:46+00:00" }, { "name": "laravel/socialite", - "version": "v5.24.0", + "version": "v5.24.2", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd" + "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/1d19358c28e8951dde6e36603b89d8f09e6cfbfd", - "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd", + "url": "https://api.github.com/repos/laravel/socialite/zipball/5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", + "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", "shasum": "" }, "require": { "ext-json": "*", - "firebase/php-jwt": "^6.4", + "firebase/php-jwt": "^6.4|^7.0", "guzzlehttp/guzzle": "^6.0|^7.0", "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", @@ -2151,20 +2255,20 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2025-12-09T15:37:06+00:00" + "time": "2026-01-10T16:07:28+00:00" }, { "name": "laravel/tinker", - "version": "v2.10.2", + "version": "v2.11.0", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c" + "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3bcb5f62d6f837e0f093a601e26badafb127bd4c", - "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c", + "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", + "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", "shasum": "" }, "require": { @@ -2173,7 +2277,7 @@ "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^7.2.5|^8.0", "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", @@ -2215,9 +2319,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.10.2" + "source": "https://github.com/laravel/tinker/tree/v2.11.0" }, - "time": "2025-11-20T16:29:12+00:00" + "time": "2025-12-19T19:16:45+00:00" }, { "name": "league/commonmark", @@ -4818,6 +4922,74 @@ }, "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" + }, { "name": "spatie/browsershot", "version": "5.2.0", @@ -4949,16 +5121,16 @@ }, { "name": "spatie/temporary-directory", - "version": "2.3.0", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/spatie/temporary-directory.git", - "reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b" + "reference": "662e481d6ec07ef29fd05010433428851a42cd07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/580eddfe9a0a41a902cac6eeb8f066b42e65a32b", - "reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/662e481d6ec07ef29fd05010433428851a42cd07", + "reference": "662e481d6ec07ef29fd05010433428851a42cd07", "shasum": "" }, "require": { @@ -4994,7 +5166,7 @@ ], "support": { "issues": "https://github.com/spatie/temporary-directory/issues", - "source": "https://github.com/spatie/temporary-directory/tree/2.3.0" + "source": "https://github.com/spatie/temporary-directory/tree/2.3.1" }, "funding": [ { @@ -5006,7 +5178,7 @@ "type": "github" } ], - "time": "2025-01-13T13:04:43+00:00" + "time": "2026-01-12T07:42:22+00:00" }, { "name": "stevebauman/purify", @@ -8096,16 +8268,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.16.0", + "version": "v7.16.1", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "a10878ed0fe0bbc2f57c980f7a08065338b970b6" + "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/a10878ed0fe0bbc2f57c980f7a08065338b970b6", - "reference": "a10878ed0fe0bbc2f57c980f7a08065338b970b6", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", "shasum": "" }, "require": { @@ -8116,10 +8288,10 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.1", + "phpunit/php-code-coverage": "^12.5.2", "phpunit/php-file-iterator": "^6", "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.2", + "phpunit/phpunit": "^12.5.4", "sebastian/environment": "^8.0.3", "symfony/console": "^7.3.4 || ^8.0.0", "symfony/process": "^7.3.4 || ^8.0.0" @@ -8131,7 +8303,7 @@ "ext-posix": "*", "phpstan/phpstan": "^2.1.33", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.10", + "phpstan/phpstan-phpunit": "^2.0.11", "phpstan/phpstan-strict-rules": "^2.0.7", "symfony/filesystem": "^7.3.2 || ^8.0.0" }, @@ -8173,7 +8345,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.16.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" }, "funding": [ { @@ -8185,7 +8357,7 @@ "type": "paypal" } ], - "time": "2025-12-09T20:03:26+00:00" + "time": "2026-01-08T07:23:06+00:00" }, { "name": "doctrine/deprecations", @@ -8674,16 +8846,16 @@ }, { "name": "laravel/boost", - "version": "v1.8.7", + "version": "v1.8.9", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c" + "reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/7a5709a8134ed59d3e7f34fccbd74689830e296c", - "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c", + "url": "https://api.github.com/repos/laravel/boost/zipball/1f2c2d41b5216618170fb6730ec13bf894c5bffd", + "reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd", "shasum": "" }, "require": { @@ -8736,20 +8908,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-12-19T15:04:12+00:00" + "time": "2026-01-07T18:43:11+00:00" }, { "name": "laravel/mcp", - "version": "v0.5.1", + "version": "v0.5.2", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4" + "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4", - "reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4", + "url": "https://api.github.com/repos/laravel/mcp/zipball/b9bdd8d6f8b547c8733fe6826b1819341597ba3c", + "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c", "shasum": "" }, "require": { @@ -8809,7 +8981,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-12-17T06:14:23+00:00" + "time": "2025-12-19T19:32:34+00:00" }, { "name": "laravel/pail", @@ -8892,16 +9064,16 @@ }, { "name": "laravel/pint", - "version": "v1.26.0", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", - "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", + "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", "shasum": "" }, "require": { @@ -8912,9 +9084,9 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.90.0", - "illuminate/view": "^12.40.1", - "larastan/larastan": "^3.8.0", + "friendsofphp/php-cs-fixer": "^3.92.4", + "illuminate/view": "^12.44.0", + "larastan/larastan": "^3.8.1", "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", @@ -8955,7 +9127,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-11-25T21:15:52+00:00" + "time": "2026-01-05T16:49:17+00:00" }, { "name": "laravel/roster", @@ -9020,16 +9192,16 @@ }, { "name": "laravel/sail", - "version": "v1.51.0", + "version": "v1.52.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "1c74357df034e869250b4365dd445c9f6ba5d068" + "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/1c74357df034e869250b4365dd445c9f6ba5d068", - "reference": "1c74357df034e869250b4365dd445c9f6ba5d068", + "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", "shasum": "" }, "require": { @@ -9079,7 +9251,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-12-09T13:33:49+00:00" + "time": "2026-01-01T02:46:03+00:00" }, { "name": "mockery/mockery", @@ -10149,16 +10321,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.0", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/16dbf9937da8d4528ceb2145c9c7c0bd29e26374", + "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374", "shasum": "" }, "require": { @@ -10190,9 +10362,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.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.1" }, - "time": "2025-08-30T15:50:23+00:00" + "time": "2026-01-12T11:33:04+00:00" }, { "name": "phpstan/phpstan", @@ -10688,16 +10860,16 @@ }, { "name": "rector/rector", - "version": "2.3.0", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "f7166355dcf47482f27be59169b0825995f51c7d" + "reference": "9afc1bb43571b25629f353c61a9315b5ef31383a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/f7166355dcf47482f27be59169b0825995f51c7d", - "reference": "f7166355dcf47482f27be59169b0825995f51c7d", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/9afc1bb43571b25629f353c61a9315b5ef31383a", + "reference": "9afc1bb43571b25629f353c61a9315b5ef31383a", "shasum": "" }, "require": { @@ -10736,7 +10908,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.0" + "source": "https://github.com/rectorphp/rector/tree/2.3.1" }, "funding": [ { @@ -10744,7 +10916,7 @@ "type": "github" } ], - "time": "2025-12-25T22:00:18+00:00" + "time": "2026-01-13T15:13:58+00:00" }, { "name": "sebastian/cli-parser", @@ -11806,16 +11978,16 @@ }, { "name": "webmozart/assert", - "version": "2.0.0", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54" + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/1b34b004e35a164bc5bb6ebd33c844b2d8069a54", - "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", "shasum": "" }, "require": { @@ -11862,9 +12034,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.0.0" + "source": "https://github.com/webmozarts/assert/tree/2.1.2" }, - "time": "2025-12-16T21:36:00+00:00" + "time": "2026-01-13T14:02:24+00:00" } ], "aliases": [], diff --git a/config/trustedproxy.php b/config/trustedproxy.php new file mode 100644 index 0000000..8557288 --- /dev/null +++ b/config/trustedproxy.php @@ -0,0 +1,6 @@ + ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)), +]; diff --git a/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php b/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php new file mode 100644 index 0000000..d230657 --- /dev/null +++ b/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php @@ -0,0 +1,33 @@ +string('kind')->nullable()->index(); + }); + + // Set existing og_png and og_plus to kind "trmnl" + DeviceModel::whereIn('name', ['og_png', 'og_plus'])->update(['kind' => 'trmnl']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('device_models', function (Blueprint $table) { + $table->dropIndex(['kind']); + $table->dropColumn('kind'); + }); + } +}; diff --git a/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php b/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php new file mode 100644 index 0000000..3b9b1b7 --- /dev/null +++ b/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000..0a527d7 --- /dev/null +++ b/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php @@ -0,0 +1,28 @@ +boolean('alias')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('alias'); + }); + } +}; diff --git a/database/seeders/ExampleRecipesSeeder.php b/database/seeders/ExampleRecipesSeeder.php index 5474615..890eed9 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/public/mirror/assets/apple-touch-icon-120x120.png b/public/mirror/assets/apple-touch-icon-120x120.png new file mode 100644 index 0000000..5e51318 Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-120x120.png differ diff --git a/public/mirror/assets/apple-touch-icon-152x152.png b/public/mirror/assets/apple-touch-icon-152x152.png new file mode 100644 index 0000000..9f8d9e3 Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-152x152.png differ diff --git a/public/mirror/assets/apple-touch-icon-167x167.png b/public/mirror/assets/apple-touch-icon-167x167.png new file mode 100644 index 0000000..79d1211 Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-167x167.png differ diff --git a/public/mirror/assets/apple-touch-icon-180x180.png b/public/mirror/assets/apple-touch-icon-180x180.png new file mode 100644 index 0000000..0499ff4 Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-180x180.png differ diff --git a/public/mirror/assets/apple-touch-icon-76x76.png b/public/mirror/assets/apple-touch-icon-76x76.png new file mode 100644 index 0000000..df3943a Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-76x76.png differ diff --git a/public/mirror/assets/favicon-16x16.png b/public/mirror/assets/favicon-16x16.png new file mode 100644 index 0000000..b36f23b Binary files /dev/null and b/public/mirror/assets/favicon-16x16.png differ diff --git a/public/mirror/assets/favicon-32x32.png b/public/mirror/assets/favicon-32x32.png new file mode 100644 index 0000000..ae12e60 Binary files /dev/null and b/public/mirror/assets/favicon-32x32.png differ diff --git a/public/mirror/assets/favicon.ico b/public/mirror/assets/favicon.ico new file mode 100644 index 0000000..da17cd5 Binary files /dev/null and b/public/mirror/assets/favicon.ico differ diff --git a/public/mirror/assets/logo--brand.svg b/public/mirror/assets/logo--brand.svg new file mode 100644 index 0000000..1b84f50 --- /dev/null +++ b/public/mirror/assets/logo--brand.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/mirror/index.html b/public/mirror/index.html new file mode 100644 index 0000000..64746fe --- /dev/null +++ b/public/mirror/index.html @@ -0,0 +1,521 @@ + + + + + + TRMNL BYOS Laravel Mirror + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + \ No newline at end of file diff --git a/public/mirror/manifest.json b/public/mirror/manifest.json new file mode 100644 index 0000000..4d44e44 --- /dev/null +++ b/public/mirror/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "TRMNL BYOS Laravel Mirror", + "short_name": "TRMNL BYOS", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff" +} diff --git a/resources/css/app.css b/resources/css/app.css index 30cb7a1..de95b81 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -72,3 +72,39 @@ select:focus[data-flux-control] { /* \[:where(&)\]:size-4 { @apply size-4; } */ + +@layer components { + /* standard container for app */ + .styled-container, + .tab-button { + @apply rounded-xl border bg-white dark:bg-stone-950 dark:border-zinc-700 text-stone-800 shadow-xs; + } + + .tab-button { + @apply flex items-center gap-2 px-4 py-2 text-sm font-medium; + @apply rounded-b-none shadow-none bg-inherit; + + /* This makes the button sit slightly over the box border */ + margin-bottom: -1px; + position: relative; + z-index: 1; + } + + .tab-button.is-active { + @apply text-zinc-700 dark:text-zinc-300; + @apply border-b-white dark:border-b-zinc-800; + + /* Z-index 10 ensures the bottom border of the tab hides the top border of the box */ + z-index: 10; + } + + .tab-button:not(.is-active) { + @apply text-zinc-500 border-transparent; + } + + .tab-button:not(.is-active):hover { + @apply text-zinc-700 dark:text-zinc-300; + @apply border-zinc-300 dark:border-zinc-700; + cursor: pointer; + } +} diff --git a/resources/views/components/layouts/auth/card.blade.php b/resources/views/components/layouts/auth/card.blade.php index 1a316ef..b5a62c6 100644 --- a/resources/views/components/layouts/auth/card.blade.php +++ b/resources/views/components/layouts/auth/card.blade.php @@ -15,7 +15,7 @@
-
+
{{ $slot }}
diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 7257ab0..fdf7f34 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -113,7 +113,8 @@ class extends Component auth()->user(), $plugin['zip_entry_path'] ?? null, null, - $plugin['logo_url'] ?? null + $plugin['logo_url'] ?? null, + allowDuplicate: true ); $this->dispatch('plugin-installed'); diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 9ecad1a..cc8b070 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -164,7 +164,8 @@ class extends Component auth()->user(), null, config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null, - $recipe['icon_url'] ?? null + $recipe['icon_url'] ?? null, + allowDuplicate: true ); $this->dispatch('plugin-installed'); diff --git a/resources/views/livewire/device-dashboard.blade.php b/resources/views/livewire/device-dashboard.blade.php index 5db65d1..7fd48a8 100644 --- a/resources/views/livewire/device-dashboard.blade.php +++ b/resources/views/livewire/device-dashboard.blade.php @@ -16,7 +16,7 @@ new class extends Component { @if($devices->isEmpty())
+ class="styled-container">

Add your first device

+ class="styled-container">
@php $current_image_uuid =$device->current_screen_image; diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index 30b4481..f9d49ca 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -309,7 +309,7 @@ new class extends Component {
+ class="styled-container">
@php $current_image_uuid =$device->current_screen_image; @@ -368,6 +368,10 @@ new class extends Component { Update Firmware Show Logs + + Mirror URL + + Delete Device @@ -498,6 +502,26 @@ new class extends Component { + + @php + $mirrorUrl = url('/mirror/index.html') . '?mac_address=' . urlencode($device->mac_address) . '&api_key=' . urlencode($device->api_key); + @endphp + +
+
+ Mirror WebUI + Mirror this device onto older devices with a web browser — Safari is supported back to iOS 9. +
+ + +
+
+ @if(!$device->mirror_device_id) @if($current_image_path) diff --git a/resources/views/livewire/playlists/index.blade.php b/resources/views/livewire/playlists/index.blade.php index 3e786b4..6c979e6 100644 --- a/resources/views/livewire/playlists/index.blade.php +++ b/resources/views/livewire/playlists/index.blade.php @@ -332,7 +332,7 @@ new class extends Component { @endforeach @if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty())) -
+

No playlists found

Add playlists to your devices to see them here.

diff --git a/resources/views/livewire/plugins/config-modal.blade.php b/resources/views/livewire/plugins/config-modal.blade.php new file mode 100644 index 0000000..7aaacbb --- /dev/null +++ b/resources/views/livewire/plugins/config-modal.blade.php @@ -0,0 +1,516 @@ + loadData(); + } + + public function loadData(): void + { + $this->resetErrorBag(); + // Reload data + $this->plugin = $this->plugin->fresh(); + + $this->configuration_template = $this->plugin->configuration_template ?? []; + $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : []; + + // Initialize multiValues by exploding the CSV strings from the DB + foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { + if (($field['field_type'] ?? null) === 'multi_string') { + $fieldKey = $field['keyname']; + $rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? ''); + + $currentValue = is_array($rawValue) ? '' : (string)$rawValue; + + $this->multiValues[$fieldKey] = $currentValue !== '' + ? array_values(array_filter(explode(',', $currentValue))) + : ['']; + } + } + } + + /** + * Triggered by @close on the modal to discard any typed but unsaved changes + */ + public int $resetIndex = 0; // Add this property + public function resetForm(): void + { + $this->loadData(); + $this->resetIndex++; // Increment to force DOM refresh + } + + public function saveConfiguration() + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + // final validation layer + $this->validate([ + 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'], + ], [ + 'multiValues.*.*.regex' => 'Items cannot contain commas.', + ]); + + // Prepare config copy to send to db + $finalValues = $this->configuration; + foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { + $fieldKey = $field['keyname']; + + // Handle multi_string: Join array back to CSV string + if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) { + $finalValues[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey]))); + } + + // Handle code fields: Try to JSON decode if necessary (standard TRMNL behavior) + if ($field['field_type'] === 'code' && isset($finalValues[$fieldKey]) && is_string($finalValues[$fieldKey])) { + $decoded = json_decode($finalValues[$fieldKey], true); + if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) { + $finalValues[$fieldKey] = $decoded; + } + } + } + + // send to db + $this->plugin->update(['configuration' => $finalValues]); + $this->configuration = $finalValues; // update local state + $this->dispatch('config-updated'); // notifies listeners + Flux::modal('configuration-modal')->close(); + } + + // ------------------------------------This section contains helper functions for interacting with the form------------------------------------------------ + public function addMultiItem(string $fieldKey): void + { + $this->multiValues[$fieldKey][] = ''; + } + + public function removeMultiItem(string $fieldKey, int $index): void + { + unset($this->multiValues[$fieldKey][$index]); + + $this->multiValues[$fieldKey] = array_values($this->multiValues[$fieldKey]); + + if (empty($this->multiValues[$fieldKey])) { + $this->multiValues[$fieldKey][] = ''; + } + } + + // Livewire magic method to validate MultiValue input boxes + // Runs on every debounce + public function updatedMultiValues($value, $key) + { + $this->validate([ + 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'], + ], [ + 'multiValues.*.*.regex' => 'Items cannot contain commas.', + ]); + } + + public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + try { + $requestData = []; + if ($query !== null) { + $requestData = [ + 'function' => $fieldKey, + 'query' => $query + ]; + } + + $response = $query !== null + ? Http::post($endpoint, $requestData) + : Http::post($endpoint); + + if ($response->successful()) { + $this->xhrSelectOptions[$fieldKey] = $response->json(); + } else { + $this->xhrSelectOptions[$fieldKey] = []; + } + } catch (\Exception $e) { + $this->xhrSelectOptions[$fieldKey] = []; + } + } + + public function searchXhrSelect(string $fieldKey, string $endpoint): void + { + $query = $this->searchQueries[$fieldKey] ?? ''; + if (!empty($query)) { + $this->loadXhrSelectOptions($fieldKey, $endpoint, $query); + } + } +};?> + + +
+
+
+ Configuration + Configure your plugin settings +
+ +
+ @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/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 4347aaf..d902183 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -395,7 +395,7 @@ new class extends Component { wire:key="plugin-{{ $plugin['id'] ?? $plugin['name'] ?? $index }}" x-data="{ pluginName: {{ json_encode(strtolower($plugin['name'] ?? '')) }} }" x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())" - class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> + class="styled-container">
diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index bda8221..0e29e76 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -1,12 +1,16 @@ user()->plugins->contains($this->plugin), 403); $this->blade_code = $this->plugin->render_markup; + // required to render some stuff $this->configuration_template = $this->plugin->configuration_template ?? []; - $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : []; if ($this->plugin->render_markup_view) { try { @@ -76,24 +78,11 @@ new class extends Component { $this->fillformFields(); $this->data_payload_updated_at = $this->plugin->data_payload_updated_at; - foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { - if (($field['field_type'] ?? null) !== 'multi_string') { - continue; - } - - $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; - - // Get the existing value from the plugin's configuration - $rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? ''); - - $currentValue = is_array($rawValue) ? '' : (string)$rawValue; - - // Split CSV into array for UI boxes - $this->multiValues[$fieldKey] = $currentValue !== '' - ? array_values(array_filter(explode(',', $currentValue))) - : ['']; + // 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 @@ -287,47 +276,6 @@ new class extends Component { Flux::modal('add-to-playlist')->close(); } - public function saveConfiguration() - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - - foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { - $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; - - if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) { - // Join the boxes into a CSV string, trimming whitespace and filtering empties - $this->configuration[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey]))); - } - } - $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(); @@ -348,8 +296,6 @@ new class extends Component { return $this->configuration[$key] ?? $default; } - - public function renderExample(string $example) { switch ($example) { @@ -418,13 +364,17 @@ HTML; { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + $this->preview_size = $size; + // If data strategy is polling and data_payload is null, fetch the data first if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) { $this->updateData(); } try { - $previewMarkup = $this->plugin->render($size); + // Create a device object with og_plus model and the selected bitdepth + $device = $this->createPreviewDevice(); + $previewMarkup = $this->plugin->render($size, true, $device); $this->dispatch('preview-updated', preview: $previewMarkup); } catch (LiquidException $e) { $this->dispatch('preview-error', message: $e->toLiquidErrorMessage()); @@ -433,6 +383,38 @@ HTML; } } + private function createPreviewDevice(): \App\Models\Device + { + $deviceModel = DeviceModel::with(['palette'])->find($this->preview_device_model_id) + ?? DeviceModel::with(['palette'])->first(); + + $device = new Device(); + $device->setRelation('deviceModel', $deviceModel); + + return $device; + } + + public function getDeviceModels() + { + return DeviceModel::whereKind('trmnl')->orderBy('label')->get(); + } + + public function updatedPreviewDeviceModelId(): void + { + $this->renderPreview($this->preview_size); + } + + public function duplicatePlugin(): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + // Use the model's duplicate method + $newPlugin = $this->plugin->duplicate(auth()->id()); + + // Redirect to the new plugin's detail page + $this->redirect(route('plugins.recipe', ['plugin' => $newPlugin])); + } + public function deletePlugin(): void { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); @@ -440,58 +422,31 @@ HTML; $this->redirect(route('plugins.index')); } - public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + #[On('config-updated')] + public function refreshPlugin() + { + // This pulls the fresh 'configuration' from the DB + // and re-triggers the @if check in the Blade template + $this->plugin = $this->plugin->fresh(); + } - try { - $requestData = []; - if ($query !== null) { - $requestData = [ - 'function' => $fieldKey, - 'query' => $query - ]; - } - - $response = $query !== null - ? Http::post($endpoint, $requestData) - : Http::post($endpoint); - - if ($response->successful()) { - $this->xhrSelectOptions[$fieldKey] = $response->json(); - } else { - $this->xhrSelectOptions[$fieldKey] = []; - } - } catch (\Exception $e) { - $this->xhrSelectOptions[$fieldKey] = []; - } + // Laravel Livewire computed property: access with $this->parsed_urls + #[Computed] + private function parsedUrls() + { + if (!isset($this->polling_url)) { + return null; } - public function searchXhrSelect(string $fieldKey, string $endpoint): void - { - $query = $this->searchQueries[$fieldKey] ?? ''; - if (!empty($query)) { - $this->loadXhrSelectOptions($fieldKey, $endpoint, $query); - } + try { + return $this->plugin->resolveLiquidVariables($this->polling_url); + + } catch (\Exception $e) { + return 'PARSE_ERROR: ' . $e->getMessage(); } + } - 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][] = ''; - } - } } - ?>
@@ -523,7 +478,6 @@ HTML; - @@ -533,6 +487,11 @@ HTML; + + Recipe Settings + + + Duplicate Plugin Delete Plugin @@ -674,8 +633,15 @@ HTML; -
+
Preview {{ $plugin->name }} + + + @foreach($this->getDeviceModels() as $model) + + @endforeach + +
@@ -683,355 +649,9 @@ 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'] ?? ''); - - # 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 -
- @endforeach - - - Add Item - -
- - - {!! $safeHelp !!} -
- {{-- @elseif($field['field_type'] === 'multi_string') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - {{-- --}} - - {{-- {!! $safeHelp !!} - --}} - @else - Field type "{{ $field['field_type'] }}" not yet supported - @endif -
- @endforeach - @endif - -
- - Save Configuration -
-
-
-
+

Settings

@@ -1119,7 +739,7 @@ HTML; @endif
- Configuration + Configuration Fields
@endif @@ -1132,15 +752,62 @@ HTML;
@if($data_strategy === 'polling') -
- Polling URL + +
@@ -1283,7 +950,7 @@ HTML; />
diff --git a/resources/views/livewire/plugins/recipes/settings.blade.php b/resources/views/livewire/plugins/recipes/settings.blade.php new file mode 100644 index 0000000..8ae3d6f --- /dev/null +++ b/resources/views/livewire/plugins/recipes/settings.blade.php @@ -0,0 +1,104 @@ +resetErrorBag(); + // Reload data + $this->plugin = $this->plugin->fresh(); + $this->trmnlp_id = $this->plugin->trmnlp_id; + $this->uuid = $this->plugin->uuid; + $this->alias = $this->plugin->alias ?? false; + } + + public function saveTrmnlpId(): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + $this->validate([ + 'trmnlp_id' => [ + 'nullable', + 'string', + 'max:255', + Rule::unique('plugins', 'trmnlp_id') + ->where('user_id', auth()->id()) + ->ignore($this->plugin->id), + ], + 'alias' => 'boolean', + ]); + + $this->plugin->update([ + 'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id, + 'alias' => $this->alias, + ]); + + Flux::modal('trmnlp-settings')->close(); + } + + public function getAliasUrlProperty(): string + { + return url("/api/display/{$this->uuid}/alias"); + } +};?> + + +
+
+ Recipe Settings +
+ +
+
+ {{-- --}} + + 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/zen.blade.php b/resources/views/recipes/zen.blade.php index 5e01eac..0ae920f 100644 --- a/resources/views/recipes/zen.blade.php +++ b/resources/views/recipes/zen.blade.php @@ -3,11 +3,11 @@ -
{{$data[0]['a']}}
- @if (strlen($data[0]['q']) < 300 && $size != 'quadrant') -

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

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

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

@else -

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

+

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

@endif
diff --git a/routes/api.php b/routes/api.php index 5700a43..d201312 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,15 +18,13 @@ use Illuminate\Support\Str; Route::get('/display', function (Request $request) { $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('mac_address', mb_strtoupper($mac_address ?? '')) - ->where('api_key', $access_token) - ->first(); + $device = Device::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) { + if ($auto_assign_user && $mac_address) { // Create a new device and assign it to this user $device = Device::create([ 'mac_address' => mb_strtoupper($mac_address ?? ''), @@ -39,7 +37,7 @@ Route::get('/display', function (Request $request) { ]); } else { return response()->json([ - 'message' => 'MAC Address not registered or invalid access token', + 'message' => 'MAC Address not registered (or not set), or invalid access token', ], 404); } } @@ -613,7 +611,7 @@ Route::post('plugin_settings/{uuid}/image', function (Request $request, string $ } // Generate a new UUID for each image upload to prevent device caching - $imageUuid = \Illuminate\Support\Str::uuid()->toString(); + $imageUuid = Str::uuid()->toString(); $filename = $imageUuid.'.'.$extension; $path = 'images/generated/'.$filename; @@ -678,3 +676,90 @@ Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, s ], ]); })->middleware('auth:sanctum'); + +Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) { + $plugin = Plugin::where('uuid', $uuid)->firstOrFail(); + + // Check if alias is active + if (! $plugin->alias) { + return response()->json([ + 'message' => 'Alias is not active for this plugin', + ], 403); + } + + // Get device model name from query parameter, default to 'og_png' + $deviceModelName = $request->query('device-model', 'og_png'); + $deviceModel = DeviceModel::where('name', $deviceModelName)->first(); + + if (! $deviceModel) { + return response()->json([ + 'message' => "Device model '{$deviceModelName}' not found", + ], 404); + } + + // Check if we can use cached image (only for og_png and if data is not stale) + $useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null; + + if ($useCache) { + // Return cached image + $imageUuid = $plugin->current_image; + $fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; + $imagePath = 'images/generated/'.$imageUuid.'.'.$fileExtension; + + // Check if image exists, otherwise fall back to generation + if (Storage::disk('public')->exists($imagePath)) { + return response()->file(Storage::disk('public')->path($imagePath), [ + 'Content-Type' => $deviceModel->mime_type, + ]); + } + } + + // Generate new image + try { + // Update data if needed + if ($plugin->isDataStale()) { + $plugin->updateDataPayload(); + $plugin->refresh(); + } + + // Load device model with palette relationship + $deviceModel->load('palette'); + + // Create a virtual device for rendering (Plugin::render needs a Device object) + $virtualDevice = new Device(); + $virtualDevice->setRelation('deviceModel', $deviceModel); + $virtualDevice->setRelation('user', $plugin->user); + $virtualDevice->setRelation('palette', $deviceModel->palette); + + // Render the plugin markup + $markup = $plugin->render(device: $virtualDevice); + + // Generate image using the new method that doesn't require a device + $imageUuid = ImageGenerationService::generateImageFromModel( + markup: $markup, + deviceModel: $deviceModel, + user: $plugin->user, + palette: $deviceModel->palette + ); + + // Update plugin cache if using og_png + if ($deviceModelName === 'og_png') { + $plugin->update(['current_image' => $imageUuid]); + } + + // Return the generated image + $fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; + $imagePath = Storage::disk('public')->path('images/generated/'.$imageUuid.'.'.$fileExtension); + + return response()->file($imagePath, [ + 'Content-Type' => $deviceModel->mime_type, + ]); + } catch (Exception $e) { + Log::error("Failed to generate alias image for plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage()); + + return response()->json([ + 'message' => 'Failed to generate image', + 'error' => $e->getMessage(), + ], 500); + } +})->name('api.display.alias'); diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index 2925a5e..c98cb2f 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -263,7 +263,7 @@ test('invalid device credentials return error', function (): void { ])->get('/api/display'); $response->assertNotFound() - ->assertJson(['message' => 'MAC Address not registered or invalid access token']); + ->assertJson(['message' => 'MAC Address not registered (or not set), or invalid access token']); }); test('log endpoint requires valid device credentials', function (): void { diff --git a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php index 7674d7f..f0be135 100644 --- a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php +++ b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php @@ -44,6 +44,7 @@ test('fetch device models job handles successful api response', function (): voi 'mime_type' => 'image/png', 'offset_x' => 0, 'offset_y' => 0, + 'kind' => 'trmnl', 'published_at' => '2023-01-01T00:00:00Z', ], ], @@ -74,6 +75,7 @@ test('fetch device models job handles successful api response', function (): voi expect($deviceModel->mime_type)->toBe('image/png'); expect($deviceModel->offset_x)->toBe(0); expect($deviceModel->offset_y)->toBe(0); + // expect($deviceModel->kind)->toBe('trmnl'); expect($deviceModel->source)->toBe('api'); }); @@ -312,6 +314,7 @@ test('fetch device models job handles device model with partial data', function expect($deviceModel->mime_type)->toBe(''); expect($deviceModel->offset_x)->toBe(0); expect($deviceModel->offset_y)->toBe(0); + expect($deviceModel->kind)->toBeNull(); expect($deviceModel->source)->toBe('api'); }); diff --git a/tests/Feature/Livewire/Plugins/ConfigModalTest.php b/tests/Feature/Livewire/Plugins/ConfigModalTest.php new file mode 100644 index 0000000..4372991 --- /dev/null +++ b/tests/Feature/Livewire/Plugins/ConfigModalTest.php @@ -0,0 +1,124 @@ +create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration_template' => [ + 'custom_fields' => [[ + 'keyname' => 'tags', + 'field_type' => 'multi_string', + 'name' => 'Reading Days', + 'default' => 'alpha,beta', + ]] + ], + 'configuration' => ['tags' => 'alpha,beta'] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->assertSet('multiValues.tags', ['alpha', 'beta']); +}); + +test('config modal validates against commas in multi_string boxes', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration_template' => [ + 'custom_fields' => [[ + 'keyname' => 'tags', + 'field_type' => 'multi_string', + 'name' => 'Reading Days', + ]] + ] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->set('multiValues.tags.0', 'no,commas,allowed') + ->call('saveConfiguration') + ->assertHasErrors(['multiValues.tags.0' => 'regex']); + + // Assert DB remains unchanged + expect($plugin->fresh()->configuration['tags'] ?? '')->not->toBe('no,commas,allowed'); +}); + +test('config modal merges multi_string boxes into a single CSV string on save', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration_template' => [ + 'custom_fields' => [[ + 'keyname' => 'items', + 'field_type' => 'multi_string', + 'name' => 'Reading Days', + ]] + ], + 'configuration' => [] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->set('multiValues.items.0', 'First') + ->call('addMultiItem', 'items') + ->set('multiValues.items.1', 'Second') + ->call('saveConfiguration') + ->assertHasNoErrors(); + + expect($plugin->fresh()->configuration['items'])->toBe('First,Second'); +}); + +test('config modal resetForm clears dirty state and increments resetIndex', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration' => ['simple_key' => 'original_value'] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->set('configuration.simple_key', 'dirty_value') + ->call('resetForm') + ->assertSet('configuration.simple_key', 'original_value') + ->assertSet('resetIndex', 1); +}); + +test('config modal dispatches update event for parent warning refresh', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static' + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->call('saveConfiguration') + ->assertDispatched('config-updated'); +}); diff --git a/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php b/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php new file mode 100644 index 0000000..a04815f --- /dev/null +++ b/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php @@ -0,0 +1,112 @@ +create(); + $this->actingAs($user); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'trmnlp_id' => null, + ]); + + $trmnlpId = (string) Str::uuid(); + + Volt::test('plugins.recipes.settings', ['plugin' => $plugin]) + ->set('trmnlp_id', $trmnlpId) + ->call('saveTrmnlpId') + ->assertHasNoErrors(); + + expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId); +}); + +test('recipe settings validates trmnlp_id is unique per user', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $existingPlugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'trmnlp_id' => 'existing-id-123', + ]); + + $newPlugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'trmnlp_id' => null, + ]); + + Volt::test('plugins.recipes.settings', ['plugin' => $newPlugin]) + ->set('trmnlp_id', 'existing-id-123') + ->call('saveTrmnlpId') + ->assertHasErrors(['trmnlp_id' => 'unique']); + + expect($newPlugin->fresh()->trmnlp_id)->toBeNull(); +}); + +test('recipe settings allows same trmnlp_id for different users', function (): void { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $plugin1 = Plugin::factory()->create([ + 'user_id' => $user1->id, + 'trmnlp_id' => 'shared-id-123', + ]); + + $plugin2 = Plugin::factory()->create([ + 'user_id' => $user2->id, + 'trmnlp_id' => null, + ]); + + $this->actingAs($user2); + + Volt::test('plugins.recipes.settings', ['plugin' => $plugin2]) + ->set('trmnlp_id', 'shared-id-123') + ->call('saveTrmnlpId') + ->assertHasNoErrors(); + + expect($plugin2->fresh()->trmnlp_id)->toBe('shared-id-123'); +}); + +test('recipe settings allows same trmnlp_id for the same plugin', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $trmnlpId = (string) Str::uuid(); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'trmnlp_id' => $trmnlpId, + ]); + + Volt::test('plugins.recipes.settings', ['plugin' => $plugin]) + ->set('trmnlp_id', $trmnlpId) + ->call('saveTrmnlpId') + ->assertHasNoErrors(); + + expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId); +}); + +test('recipe settings can clear trmnlp_id', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'trmnlp_id' => 'some-id', + ]); + + Volt::test('plugins.recipes.settings', ['plugin' => $plugin]) + ->set('trmnlp_id', '') + ->call('saveTrmnlpId') + ->assertHasNoErrors(); + + expect($plugin->fresh()->trmnlp_id)->toBeNull(); +}); diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php index 1b20f93..f3ef1fa 100644 --- a/tests/Feature/PluginImportTest.php +++ b/tests/Feature/PluginImportTest.php @@ -83,19 +83,34 @@ it('throws exception for invalid zip file', function (): void { ->toThrow(Exception::class, 'Could not open the ZIP file.'); }); -it('throws exception for missing required files', function (): void { +it('throws exception for missing settings.yml', function (): void { $user = User::factory()->create(); $zipContent = createMockZipFile([ - 'src/settings.yml' => getValidSettingsYaml(), - // Missing full.liquid + 'src/full.liquid' => getValidFullLiquid(), + // Missing settings.yml ]); $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); $pluginImportService = new PluginImportService(); expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user)) - ->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); + ->toThrow(Exception::class, 'Invalid ZIP structure. Required file settings.yml is missing.'); +}); + +it('throws exception for missing template files', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + // Missing all template files + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user)) + ->toThrow(Exception::class, 'Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.'); }); it('sets default values when settings are missing', function (): void { @@ -427,6 +442,103 @@ YAML; ->and($displayIncidentField['default'])->toBe('true'); }); +it('throws exception when multi_string default value contains a comma', function (): void { + $user = User::factory()->create(); + + // YAML with a comma in the 'default' field of a multi_string + $invalidYaml = <<<'YAML' +name: Test Plugin +refresh_interval: 30 +strategy: static +polling_verb: get +static_data: '{"test": "data"}' +custom_fields: + - keyname: api_key + field_type: multi_string + default: default-api-key1,default-api-key2 + label: API Key +YAML; + + $zipContent = createMockZipFile([ + 'src/settings.yml' => $invalidYaml, + 'src/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('invalid-default.zip', $zipContent); + $pluginImportService = new PluginImportService(); + + expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) + ->toThrow(Exception::class, 'Validation Error: The default value for multistring fields like `api_key` cannot contain commas.'); +}); + +it('throws exception when multi_string placeholder contains a comma', function (): void { + $user = User::factory()->create(); + + // YAML with a comma in the 'placeholder' field + $invalidYaml = <<<'YAML' +name: Test Plugin +refresh_interval: 30 +strategy: static +polling_verb: get +static_data: '{"test": "data"}' +custom_fields: + - keyname: api_key + field_type: multi_string + default: default-api-key + label: API Key + placeholder: "value1, value2" +YAML; + + $zipContent = createMockZipFile([ + 'src/settings.yml' => $invalidYaml, + 'src/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('invalid-placeholder.zip', $zipContent); + $pluginImportService = new PluginImportService(); + + expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) + ->toThrow(Exception::class, 'Validation Error: The placeholder value for multistring fields like `api_key` cannot contain commas.'); +}); + +it('imports plugin with only shared.liquid file', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + 'src/shared.liquid' => '
{{ data.title }}
', + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->markup_language)->toBe('liquid') + ->and($plugin->render_markup)->toContain('
') + ->and($plugin->render_markup)->toContain('
{{ data.title }}
'); +}); + +it('imports plugin with only shared.blade.php file', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + 'src/shared.blade.php' => '
{{ $data["title"] }}
', + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->markup_language)->toBe('blade') + ->and($plugin->render_markup)->toBe('
{{ $data["title"] }}
') + ->and($plugin->render_markup)->not->toContain('
'); +}); + // Helper methods function createMockZipFile(array $files): string { diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index 0847e36..aa9a28e 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -99,6 +99,35 @@ test('updateDataPayload handles multiple URLs with IDX_ prefixes', function (): expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']); }); +test('updateDataPayload skips empty lines in polling_url and maintains sequential IDX keys', function (): void { + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + // empty lines and extra spaces between the URL to generate empty entries + 'polling_url' => "https://api1.example.com/data\n \n\nhttps://api2.example.com/weather\n ", + 'polling_verb' => 'get', + ]); + + // Mock only the valid URLs + Http::fake([ + 'https://api1.example.com/data' => Http::response(['item' => 'first'], 200), + 'https://api2.example.com/weather' => Http::response(['item' => 'second'], 200), + ]); + + $plugin->updateDataPayload(); + + // payload should only have 2 items, and they should be indexed 0 and 1 + expect($plugin->data_payload)->toHaveCount(2); + expect($plugin->data_payload)->toHaveKey('IDX_0'); + expect($plugin->data_payload)->toHaveKey('IDX_1'); + + // data is correct + expect($plugin->data_payload['IDX_0'])->toBe(['item' => 'first']); + expect($plugin->data_payload['IDX_1'])->toBe(['item' => 'second']); + + // no empty index exists + expect($plugin->data_payload)->not->toHaveKey('IDX_2'); +}); + test('updateDataPayload handles single URL without nesting', function (): void { $plugin = Plugin::factory()->create([ 'data_strategy' => 'polling', @@ -737,3 +766,175 @@ test('plugin model preserves multi_string csv format', function (): void { expect($plugin->fresh()->configuration['tags'])->toBe('laravel,pest,security'); }); + +test('plugin duplicate copies all attributes except id and uuid', function (): void { + $user = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Original Plugin', + 'data_stale_minutes' => 30, + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.example.com/data', + 'polling_verb' => 'get', + 'polling_header' => 'Authorization: Bearer token123', + 'polling_body' => '{"query": "test"}', + 'render_markup' => '
Test markup
', + 'markup_language' => 'blade', + 'configuration' => ['api_key' => 'secret123'], + 'configuration_template' => [ + 'custom_fields' => [ + [ + 'keyname' => 'api_key', + 'field_type' => 'string', + ], + ], + ], + 'no_bleed' => true, + 'dark_mode' => true, + 'data_payload' => ['test' => 'data'], + ]); + + $duplicate = $original->duplicate(); + + // Refresh to ensure casts are applied + $original->refresh(); + $duplicate->refresh(); + + expect($duplicate->id)->not->toBe($original->id) + ->and($duplicate->uuid)->not->toBe($original->uuid) + ->and($duplicate->name)->toBe('Original Plugin (Copy)') + ->and($duplicate->user_id)->toBe($original->user_id) + ->and($duplicate->data_stale_minutes)->toBe($original->data_stale_minutes) + ->and($duplicate->data_strategy)->toBe($original->data_strategy) + ->and($duplicate->polling_url)->toBe($original->polling_url) + ->and($duplicate->polling_verb)->toBe($original->polling_verb) + ->and($duplicate->polling_header)->toBe($original->polling_header) + ->and($duplicate->polling_body)->toBe($original->polling_body) + ->and($duplicate->render_markup)->toBe($original->render_markup) + ->and($duplicate->markup_language)->toBe($original->markup_language) + ->and($duplicate->configuration)->toBe($original->configuration) + ->and($duplicate->configuration_template)->toBe($original->configuration_template) + ->and($duplicate->no_bleed)->toBe($original->no_bleed) + ->and($duplicate->dark_mode)->toBe($original->dark_mode) + ->and($duplicate->data_payload)->toBe($original->data_payload) + ->and($duplicate->render_markup_view)->toBeNull(); +}); + +test('plugin duplicate copies render_markup_view file content to render_markup', function (): void { + $user = User::factory()->create(); + + // Create a test blade file + $testViewPath = resource_path('views/recipes/test-duplicate.blade.php'); + $testContent = '
Test Content
'; + + // Ensure directory exists + if (! is_dir(dirname($testViewPath))) { + mkdir(dirname($testViewPath), 0755, true); + } + + file_put_contents($testViewPath, $testContent); + + try { + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'View Plugin', + 'render_markup' => null, + 'render_markup_view' => 'recipes.test-duplicate', + 'markup_language' => null, + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->render_markup)->toBe($testContent) + ->and($duplicate->markup_language)->toBe('blade') + ->and($duplicate->render_markup_view)->toBeNull() + ->and($duplicate->name)->toBe('View Plugin (Copy)'); + } finally { + // Clean up test file + if (file_exists($testViewPath)) { + unlink($testViewPath); + } + } +}); + +test('plugin duplicate handles liquid file extension', function (): void { + $user = User::factory()->create(); + + // Create a test liquid file + $testViewPath = resource_path('views/recipes/test-duplicate-liquid.liquid'); + $testContent = '
{{ data.message }}
'; + + // Ensure directory exists + if (! is_dir(dirname($testViewPath))) { + mkdir(dirname($testViewPath), 0755, true); + } + + file_put_contents($testViewPath, $testContent); + + try { + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Liquid Plugin', + 'render_markup' => null, + 'render_markup_view' => 'recipes.test-duplicate-liquid', + 'markup_language' => null, + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->render_markup)->toBe($testContent) + ->and($duplicate->markup_language)->toBe('liquid') + ->and($duplicate->render_markup_view)->toBeNull(); + } finally { + // Clean up test file + if (file_exists($testViewPath)) { + unlink($testViewPath); + } + } +}); + +test('plugin duplicate handles missing view file gracefully', function (): void { + $user = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Missing View Plugin', + 'render_markup' => null, + 'render_markup_view' => 'recipes.nonexistent-view', + 'markup_language' => null, + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->render_markup_view)->toBeNull() + ->and($duplicate->name)->toBe('Missing View Plugin (Copy)'); +}); + +test('plugin duplicate uses provided user_id', function (): void { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user1->id, + 'name' => 'Original Plugin', + ]); + + $duplicate = $original->duplicate($user2->id); + + expect($duplicate->user_id)->toBe($user2->id) + ->and($duplicate->user_id)->not->toBe($original->user_id); +}); + +test('plugin duplicate falls back to original user_id when no user_id provided', function (): void { + $user = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Original Plugin', + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->user_id)->toBe($original->user_id); +});