diff --git a/app/Liquid/FileSystems/InlineTemplatesFileSystem.php b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php new file mode 100644 index 0000000..01adf1b --- /dev/null +++ b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php @@ -0,0 +1,61 @@ + + */ + protected array $templates = []; + + /** + * Register a template with the given name and content + */ + public function register(string $name, string $content): void + { + $this->templates[$name] = $content; + } + + /** + * Check if a template exists + */ + public function hasTemplate(string $templateName): bool + { + return isset($this->templates[$templateName]); + } + + /** + * Get all registered template names + * + * @return array + */ + public function getTemplateNames(): array + { + return array_keys($this->templates); + } + + /** + * Clear all registered templates + */ + public function clear(): void + { + $this->templates = []; + } + + public function readTemplateFile(string $templateName): string + { + if (!isset($this->templates[$templateName])) { + throw new \InvalidArgumentException("Template '{$templateName}' not found in inline templates"); + } + + return $this->templates[$templateName]; + } +} \ No newline at end of file diff --git a/app/Liquid/Tags/TemplateTag.php b/app/Liquid/Tags/TemplateTag.php new file mode 100644 index 0000000..19f664e --- /dev/null +++ b/app/Liquid/Tags/TemplateTag.php @@ -0,0 +1,99 @@ +params->expression(); + + $this->templateName = match (true) { + is_string($templateNameExpression) => trim($templateNameExpression), + is_numeric($templateNameExpression) => (string) $templateNameExpression, + $templateNameExpression instanceof VariableLookup => (string) $templateNameExpression, + default => throw new SyntaxException("Template name must be a string, number, or variable"), + }; + + // Validate template name (letters, numbers, underscores, and slashes only) + if (!preg_match('/^[a-zA-Z0-9_\/]+$/', $this->templateName)) { + throw new SyntaxException("Invalid template name '{$this->templateName}' - template names must contain only letters, numbers, underscores, and slashes"); + } + + $context->params->assertEnd(); + + assert($context->body instanceof BodyNode); + + $body = $context->body->children()[0] ?? null; + $this->body = match (true) { + $body instanceof Raw => $body, + default => throw new SyntaxException('template tag must have a single raw body'), + }; + + // Register the template with the file system during parsing + $fileSystem = $context->getParseContext()->environment->fileSystem; + if ($fileSystem instanceof InlineTemplatesFileSystem) { + // Store the raw content for later rendering + $fileSystem->register($this->templateName, $this->body->value); + } + + return $this; + } + + public function render(RenderContext $context): string + { + // Get the file system from the environment + $fileSystem = $context->environment->fileSystem; + + if (!$fileSystem instanceof InlineTemplatesFileSystem) { + // If no inline file system is available, just return empty string + // This allows the template to be used in contexts where inline templates aren't supported + return ''; + } + + // Register the template with the file system + $fileSystem->register($this->templateName, $this->body->render($context)); + + // Return empty string as template tags don't output anything + return ''; + } + + public function getTemplateName(): string + { + return $this->templateName; + } + + public function getBody(): Raw + { + return $this->body; + } +} \ No newline at end of file diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 6c17101..e2b3260 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -2,18 +2,23 @@ namespace App\Models; +use App\Liquid\FileSystems\InlineTemplatesFileSystem; use App\Liquid\Filters\Data; use App\Liquid\Filters\Localization; use App\Liquid\Filters\Numbers; use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\Uniqueness; +use App\Liquid\Tags\TemplateTag; +use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Keepsuit\Liquid\Exceptions\LiquidException; +use Keepsuit\Liquid\Extensions\StandardExtension; class Plugin extends Model { @@ -26,6 +31,8 @@ class Plugin extends Model 'data_payload_updated_at' => 'datetime', 'is_native' => 'boolean', 'markup_language' => 'string', + 'configuration' => 'json', + 'configuration_template' => 'json', ]; protected static function boot() @@ -39,6 +46,49 @@ class Plugin extends Model }); } + public function user() + { + return $this->belongsTo(User::class); + } + + public function hasMissingRequiredConfigurationFields(): bool + { + if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) { + return false; + } + + foreach ($this->configuration_template['custom_fields'] as $field) { + // Skip fields as they are informational only + if ($field['field_type'] === 'author_bio') { + continue; + } + + if ($field['field_type'] === 'copyable') { + continue; + } + + if ($field['field_type'] === 'copyable_webhook_url') { + continue; + } + + $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; + + // Check if field is required (not marked as optional) + $isRequired = ! isset($field['optional']) || $field['optional'] !== true; + + if ($isRequired) { + $currentValue = $this->configuration[$fieldKey] ?? null; + + // If the field has a default value and no current value is set, it's not missing + if (($currentValue === null || $currentValue === '' || (is_array($currentValue) && empty($currentValue))) && ! isset($field['default'])) { + return true; // Found a required field that is not set and has no default + } + } + } + + return false; // All required fields are set + } + public function isDataStale(): bool { if ($this->data_strategy === 'webhook') { @@ -59,7 +109,9 @@ class Plugin extends Model $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; if ($this->polling_header) { - $headerLines = explode("\n", trim($this->polling_header)); + // Resolve Liquid variables in the polling header + $resolvedHeader = $this->resolveLiquidVariables($this->polling_header); + $headerLines = explode("\n", trim($resolvedHeader)); foreach ($headerLines as $line) { $parts = explode(':', $line, 2); if (count($parts) === 2) { @@ -68,26 +120,138 @@ class Plugin extends Model } } - $httpRequest = Http::withHeaders($headers); + // Split URLs by newline and filter out empty lines + $urls = array_filter( + array_map('trim', explode("\n", $this->polling_url)), + fn ($url) => ! empty($url) + ); - if ($this->polling_verb === 'post' && $this->polling_body) { - $httpRequest = $httpRequest->withBody($this->polling_body); + // 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); + } + + // Resolve Liquid variables in the polling URL + $resolvedUrl = $this->resolveLiquidVariables($url); + + try { + // Make the request based on the verb + if ($this->polling_verb === 'post') { + $response = $httpRequest->post($resolvedUrl)->json(); + } else { + $response = $httpRequest->get($resolvedUrl)->json(); + } + + $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; } - // Make the request based on the verb - if ($this->polling_verb === 'post') { - $response = $httpRequest->post($this->polling_url)->json(); - } else { - $response = $httpRequest->get($this->polling_url)->json(); + // 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); + } + + // Resolve Liquid variables in the polling URL + $resolvedUrl = $this->resolveLiquidVariables($url); + + try { + // Make the request based on the verb + if ($this->polling_verb === 'post') { + $response = $httpRequest->post($resolvedUrl)->json(); + } else { + $response = $httpRequest->get($resolvedUrl)->json(); + } + + // Check if response is an array at root level + if (is_array($response) && 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' => $response, + 'data_payload' => $combinedResponse, 'data_payload_updated_at' => now(), ]); } } + /** + * Apply Liquid template replacements (converts 'with' syntax to comma syntax) + */ + private function applyLiquidReplacements(string $template): string + { + $replacements = [ + 'date: "%N"' => 'date: "u"', + '%-m/%-d/%Y' => 'm/d/Y', + ]; + + // Apply basic replacements + $template = str_replace(array_keys($replacements), array_values($replacements), $template); + + // Convert {% render "template" with %} syntax to {% render "template", %} syntax + $template = preg_replace( + '/{%\s*render\s+([^}]+?)\s+with\s+/i', + '{% render $1, ', + $template + ); + + return $template; + } + + /** + * Resolve Liquid variables in a template string using the Liquid template engine + * + * @param string $template The template string containing Liquid variables + * @return string The resolved template with variables replaced with their values + * + * @throws LiquidException + */ + public function resolveLiquidVariables(string $template): string + { + // Get configuration variables - make them available at root level + $variables = $this->configuration ?? []; + + // Use the Liquid template engine to resolve variables + $environment = App::make('liquid.environment'); + $liquidTemplate = $environment->parseString($template); + $context = $environment->newRenderContext(data: $variables); + + return $liquidTemplate->render($context); + } + /** * Render the plugin's markup * @@ -99,7 +263,12 @@ class Plugin extends Model $renderedContent = ''; if ($this->markup_language === 'liquid') { - $environment = App::make('liquid.environment'); + // Create a custom environment with inline templates support + $inlineFileSystem = new InlineTemplatesFileSystem(); + $environment = new \Keepsuit\Liquid\Environment( + fileSystem: $inlineFileSystem, + extensions: [new StandardExtension()] + ); // Register all custom filters $environment->filterRegistry->register(Numbers::class); @@ -108,11 +277,47 @@ class Plugin extends Model $environment->filterRegistry->register(Uniqueness::class); $environment->filterRegistry->register(Localization::class); - $template = $environment->parseString($this->render_markup); - $context = $environment->newRenderContext(data: ['size' => $size, 'data' => $this->data_payload]); + // Register the template tag for inline templates + $environment->tagRegistry->register(TemplateTag::class); + + // Apply Liquid replacements (including 'with' syntax conversion) + $processedMarkup = $this->applyLiquidReplacements($this->render_markup); + + $template = $environment->parseString($processedMarkup); + $context = $environment->newRenderContext( + data: [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ...(is_array($this->data_payload) ? $this->data_payload : []), + 'trmnl' => [ + 'user' => [ + 'utc_offset' => '0', + 'name' => $this->user->name ?? 'Unknown User', + 'locale' => 'en', + 'time_zone_iana' => config('app.timezone'), + ], + 'plugin_settings' => [ + 'instance_name' => $this->name, + 'strategy' => $this->data_strategy, + 'dark_mode' => 'no', + 'no_screen_padding' => 'no', + 'polling_headers' => $this->polling_header, + 'polling_url' => $this->polling_url, + 'custom_fields_values' => [ + ...(is_array($this->configuration) ? $this->configuration : []), + ], + ], + ], + ] + ); $renderedContent = $template->render($context); } else { - $renderedContent = Blade::render($this->render_markup, ['size' => $size, 'data' => $this->data_payload]); + $renderedContent = Blade::render($this->render_markup, [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ]); } if ($standalone) { @@ -130,6 +335,7 @@ class Plugin extends Model 'slot' => view($this->render_markup_view, [ 'size' => $size, 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], ])->render(), ])->render(); } @@ -137,10 +343,19 @@ class Plugin extends Model return view($this->render_markup_view, [ 'size' => $size, 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], ])->render(); } return '

No render markup yet defined for this plugin.

'; } + + /** + * Get a configuration value by key + */ + public function getConfiguration(string $key, $default = null) + { + return $this->configuration[$key] ?? $default; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6ac75bf..8433d76 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Services\OidcProvider; +use Illuminate\Http\Request; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Laravel\Socialite\Facades\Socialite; @@ -26,6 +27,17 @@ class AppServiceProvider extends ServiceProvider URL::forceScheme('https'); } + Request::macro('hasValidSignature', function ($absolute = true, array $ignoreQuery = []) { + $https = clone $this; + $https->server->set('HTTPS', 'on'); + + $http = clone $this; + $http->server->set('HTTPS', 'off'); + + return URL::hasValidSignature($https, $absolute, $ignoreQuery) + || URL::hasValidSignature($http, $absolute, $ignoreQuery); + }); + // Register OIDC provider with Socialite Socialite::extend('oidc', function ($app) { $config = $app['config']['services.oidc'] ?? []; diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php new file mode 100644 index 0000000..9cf3d76 --- /dev/null +++ b/app/Services/PluginImportService.php @@ -0,0 +1,206 @@ +getRealPath(); + + // Extract the ZIP file using ZipArchive + $zip = new ZipArchive(); + if ($zip->open($zipFullPath) !== true) { + throw new Exception('Could not open the ZIP file.'); + } + + $zip->extractTo($tempDir); + $zip->close(); + + // Find the required files (settings.yml and full.liquid/full.blade.php) + $filePaths = $this->findRequiredFiles($tempDir); + + // 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.'); + } + + // Parse settings.yml + $settingsYaml = File::get($filePaths['settingsYamlPath']); + $settings = Yaml::parse($settingsYaml); + + // Read full.liquid content + $fullLiquid = File::get($filePaths['fullLiquidPath']); + $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + + // Prepend shared.liquid content if available + if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { + $sharedLiquid = File::get($filePaths['sharedLiquidPath']); + $fullLiquid = $sharedLiquid."\n".$fullLiquid; + } + + // Check if the file ends with .liquid to set markup language + $markupLanguage = 'blade'; + if (pathinfo($filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') { + $markupLanguage = 'liquid'; + } + + // Ensure custom_fields is properly formatted + if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) { + $settings['custom_fields'] = []; + } + + // Create configuration template with the custom fields + $configurationTemplate = [ + 'custom_fields' => $settings['custom_fields'], + ]; + + // Extract default values from custom_fields and populate configuration + $configuration = []; + if (isset($settings['custom_fields']) && is_array($settings['custom_fields'])) { + foreach ($settings['custom_fields'] as $field) { + if (isset($field['keyname']) && isset($field['default'])) { + $configuration[$field['keyname']] = $field['default']; + } + } + } + + // Create a new plugin + $plugin = Plugin::create([ + 'user_id' => $user->id, + 'name' => $settings['name'] ?? 'Imported Plugin', + 'data_stale_minutes' => $settings['refresh_interval'] ?? 15, + 'data_strategy' => $settings['strategy'] ?? 'static', + 'polling_url' => $settings['polling_url'] ?? null, + 'polling_verb' => $settings['polling_verb'] ?? 'get', + 'polling_header' => isset($settings['polling_headers']) + ? str_replace('=', ':', $settings['polling_headers']) + : null, + 'polling_body' => $settings['polling_body'] ?? null, + 'markup_language' => $markupLanguage, + 'render_markup' => $fullLiquid, + 'configuration_template' => $configurationTemplate, + 'configuration' => $configuration, + 'data_payload' => json_decode($settings['static_data'] ?? '{}', true), + ]); + + return $plugin; + + } finally { + // Clean up temporary directory + Storage::deleteDirectory($tempDirName); + } + } + + /** + * Find required files in the extracted ZIP directory + * + * @param string $tempDir The temporary directory path + * @return array Array containing paths to required files + */ + private function findRequiredFiles(string $tempDir): array + { + $settingsYamlPath = null; + $fullLiquidPath = null; + $sharedLiquidPath = null; + + // First, check if files are directly in the src folder + if (File::exists($tempDir.'/src/settings.yml')) { + $settingsYamlPath = $tempDir.'/src/settings.yml'; + + // Check for full.liquid or full.blade.php + if (File::exists($tempDir.'/src/full.liquid')) { + $fullLiquidPath = $tempDir.'/src/full.liquid'; + } elseif (File::exists($tempDir.'/src/full.blade.php')) { + $fullLiquidPath = $tempDir.'/src/full.blade.php'; + } + + // Check for shared.liquid in the same directory + if (File::exists($tempDir.'/src/shared.liquid')) { + $sharedLiquidPath = $tempDir.'/src/shared.liquid'; + } + } else { + // Search for the files in the extracted directory structure + $directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($directories); + + foreach ($files as $file) { + $filename = $file->getFilename(); + $filepath = $file->getPathname(); + + if ($filename === 'settings.yml') { + $settingsYamlPath = $filepath; + } elseif ($filename === 'full.liquid' || $filename === 'full.blade.php') { + $fullLiquidPath = $filepath; + } elseif ($filename === 'shared.liquid') { + $sharedLiquidPath = $filepath; + } + + // If we found both required files, break the loop + if ($settingsYamlPath && $fullLiquidPath) { + break; + } + } + + // 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 the files are in the root of the ZIP, create a src folder and move them there + $srcDir = dirname($settingsYamlPath); + + // If the parent directory is not named 'src', create a src directory + if (basename($srcDir) !== 'src') { + $newSrcDir = $tempDir.'/src'; + File::makeDirectory($newSrcDir, 0755, true); + + // 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 + if ($sharedLiquidPath) { + File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid'); + $sharedLiquidPath = $newSrcDir.'/shared.liquid'; + } + + // Update the paths + $settingsYamlPath = $newSrcDir.'/settings.yml'; + $fullLiquidPath = $newSrcDir.'/full.liquid'; + } + } + } + + return [ + 'settingsYamlPath' => $settingsYamlPath, + 'fullLiquidPath' => $fullLiquidPath, + 'sharedLiquidPath' => $sharedLiquidPath, + ]; + } +} diff --git a/composer.json b/composer.json index a2c72e2..6804500 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "require": { "php": "^8.2", "ext-imagick": "*", + "ext-zip": "*", "bnussbau/laravel-trmnl-blade": "1.2.*", "intervention/image": "^3.11", "keepsuit/laravel-liquid": "^0.5.2", @@ -22,6 +23,7 @@ "livewire/flux": "^2.0", "livewire/volt": "^1.7", "spatie/browsershot": "^5.0", + "symfony/yaml": "^7.3", "wnx/sidecar-browsershot": "^2.6" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 0c56e9b..3f04004 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": "9143c36674f3ae13a9e9bad15014d508", + "content-hash": "fea763810f7e3a912c2221d2fe0a751e", "packages": [ { "name": "aws/aws-crt-php", @@ -7405,6 +7405,82 @@ ], "time": "2025-07-10T08:47:49+00:00" }, + { + "name": "symfony/yaml", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:47:49+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.3.0", @@ -11377,82 +11453,6 @@ ], "time": "2024-10-20T05:08:20+00:00" }, - { - "name": "symfony/yaml", - "version": "v7.3.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", - "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/console": "<6.4" - }, - "require-dev": { - "symfony/console": "^6.4|^7.0" - }, - "bin": [ - "Resources/bin/yaml-lint" - ], - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Loads and dumps YAML files", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.2" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-07-10T08:47:49+00:00" - }, { "name": "ta-tikoma/phpunit-architecture-test", "version": "0.8.5", @@ -11570,7 +11570,8 @@ "prefer-lowest": false, "platform": { "php": "^8.2", - "ext-imagick": "*" + "ext-imagick": "*", + "ext-zip": "*" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/database/migrations/2025_06_13_102932_add_configuration_to_plugins_table.php b/database/migrations/2025_06_13_102932_add_configuration_to_plugins_table.php new file mode 100644 index 0000000..2ed9123 --- /dev/null +++ b/database/migrations/2025_06_13_102932_add_configuration_to_plugins_table.php @@ -0,0 +1,30 @@ +json('configuration_template')->nullable(); + $table->json('configuration')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('configuration_template'); + $table->dropColumn('configuration'); + }); + } +}; diff --git a/resources/views/flux/icon/github.blade.php b/resources/views/flux/icon/github.blade.php new file mode 100644 index 0000000..1463734 --- /dev/null +++ b/resources/views/flux/icon/github.blade.php @@ -0,0 +1,42 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 7ab674b..9a5dd69 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -1,8 +1,13 @@ @@ -50,7 +56,7 @@ new class extends Component { $this->validate(); \App\Models\Plugin::create([ - 'uuid' => \Illuminate\Support\Str::uuid(), + 'uuid' => Str::uuid(), 'user_id' => auth()->id(), 'name' => $this->name, 'data_stale_minutes' => $this->data_stale_minutes, @@ -69,8 +75,32 @@ new class extends Component { public function seedExamplePlugins(): void { - \Artisan::call(\App\Console\Commands\ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]); + Artisan::call(ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]); $this->refreshPlugins(); + + } + + + public function importZip(PluginImportService $pluginImportService): void + { + abort_unless(auth()->user() !== null, 403); + + $this->validate([ + 'zipFile' => 'required|file|mimes:zip|max:10240', // 10MB max + ]); + + try { + $plugin = $pluginImportService->importFromZip($this->zipFile, auth()->user()); + + $this->refreshPlugins(); + $this->reset(['zipFile']); + + Flux::modal('import-zip')->close(); + $this->dispatch('notify', ['type' => 'success', 'message' => 'Plugin imported successfully!']); + + } catch (\Exception $e) { + $this->dispatch('notify', ['type' => 'error', 'message' => 'Error importing plugin: ' . $e->getMessage()]); + } } }; @@ -89,15 +119,10 @@ new class extends Component { + + Import Recipe + Seed Example Recipes - {{-- --}} - {{-- --}} - {{-- Import Recipe ZIP File--}} - {{-- --}} - {{-- --}} - {{-- --}} - {{-- New Native Plugin--}} - {{-- --}} @@ -105,6 +130,62 @@ new class extends Component { + +
+
+ Import Recipe + Alpha + + Upload a ZIP archive containing a TRMNL recipe — either exported from the cloud service or structured using the trmnlp project structure. +
+ +
+ The archive must at least contain settings.yml and full.liquid files. +{{--

The ZIP file should contain the following structure:

--}} +{{--
--}}
+{{--.--}}
+{{--├── src--}}
+{{--│   ├── full.liquid (required)--}}
+{{--│   ├── settings.yml (required)--}}
+{{--│   └── ...--}}
+{{--└── ...--}}
+{{--                    
--}} +
+ +
+ Limitations +
    +
  • Only full view will be imported; shared markup will be prepended
  • +
  • Some Liquid filters may be not supported or behave differently
  • +
  • API responses in formats other than JSON are not yet supported
  • +{{--
      --}} +{{--
    • date: "%N" is unsupported. Use date: "u" instead
    • --}} +{{--
    --}} +
+ Please report issues on GitHub. Include your example zip file. +
+ +
+
+ + + @error('zipFile') {{ $message }} @enderror +
+ +
+ + Import +
+
+
+
+
diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 32f6e18..0edee8a 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -2,8 +2,10 @@ use App\Models\Plugin; use Illuminate\Support\Carbon; +use Keepsuit\Liquid\Exceptions\LiquidException; use Livewire\Volt\Component; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Arr; new class extends Component { public Plugin $plugin; @@ -28,10 +30,15 @@ new class extends Component { public string $selected_playlist = ''; public string $mashup_layout = 'full'; public array $mashup_plugins = []; + public array $configuration_template = []; + public array $configuration = []; public function mount(): void { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + $this->blade_code = $this->plugin->render_markup; + $this->configuration_template = $this->plugin->configuration_template ?? []; + $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : []; if ($this->plugin->render_markup_view) { try { @@ -86,7 +93,7 @@ new class extends Component { 'name' => 'required|string|max:255', 'data_stale_minutes' => 'required|integer|min:1', 'data_strategy' => 'required|string|in:polling,webhook,static', - 'polling_url' => 'required_if:data_strategy,polling|nullable|url', + 'polling_url' => 'required_if:data_strategy,polling|nullable', 'polling_verb' => 'required|string|in:get,post', 'polling_header' => 'nullable|string|max:255', 'polling_body' => 'nullable|string', @@ -104,11 +111,30 @@ new class extends Component { public function editSettings() { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + // Custom validation for polling_url with Liquid variable resolution + $this->validatePollingUrl(); + $validated = $this->validate(); $validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true); $this->plugin->update($validated); } + protected function validatePollingUrl(): void + { + if ($this->data_strategy === 'polling' && !empty($this->polling_url)) { + try { + $resolvedUrl = $this->plugin->resolveLiquidVariables($this->polling_url); + + if (!filter_var($resolvedUrl, FILTER_VALIDATE_URL)) { + $this->addError('polling_url', 'The polling URL must be a valid URL after resolving configuration variables.'); + } + } catch (\Exception $e) { + $this->addError('polling_url', 'Error resolving Liquid variables: ' . $e->getMessage()); + } + } + } + public function updateData(): void { if ($this->plugin->data_strategy === 'polling') { @@ -197,11 +223,39 @@ new class extends Component { Flux::modal('add-to-playlist')->close(); } + public function saveConfiguration() + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + $configurationValues = []; + if (isset($this->configuration_template['custom_fields'])) { + foreach ($this->configuration_template['custom_fields'] as $field) { + $fieldKey = $field['keyname']; + if (isset($this->configuration[$fieldKey])) { + $configurationValues[$fieldKey] = $this->configuration[$fieldKey]; + } + } + } + + $this->plugin->update([ + 'configuration' => $configurationValues + ]); + + Flux::modal('configuration-modal')->close(); + } + public function getDevicePlaylists($deviceId) { return \App\Models\Playlist::where('device_id', $deviceId)->get(); } + public function getConfigurationValue($key, $default = null) + { + return $this->configuration[$key] ?? $default; + } + + + public function renderExample(string $example) { switch ($example) { @@ -270,9 +324,16 @@ HTML; { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + // 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); $this->dispatch('preview-updated', preview: $previewMarkup); + } catch (LiquidException $e) { + $this->dispatch('preview-error', message: $e->toLiquidErrorMessage()); } catch (\Exception $e) { $this->dispatch('preview-error', message: $e->getMessage()); } @@ -297,23 +358,23 @@ HTML; - Preview + Preview - + - Half-Horizontal + Half-Horizontal - Half-Vertical + Half-Vertical - Quadrant + Quadrant @@ -321,7 +382,7 @@ HTML; - Add to Playlist + Add to Playlist @@ -429,7 +490,7 @@ HTML;
- Add to Playlist + Add to Playlist
@@ -461,6 +522,143 @@ 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']; + $currentValue = $configuration[$fieldKey] ?? ''; + @endphp +
+ @if($field['field_type'] === 'author_bio') + @continue + @endif + + @if($field['field_type'] === 'copyable_webhook_url') + @continue + @endif + + @if($field['field_type'] === 'string' || $field['field_type'] === 'url') + + @elseif($field['field_type'] === 'password') + + @elseif($field['field_type'] === 'copyable') + + @elseif($field['field_type'] === 'time_zone') + + + @foreach(timezone_identifiers_list() as $timezone) + + @endforeach + + @elseif($field['field_type'] === 'number') + + @elseif($field['field_type'] === 'boolean') + + @elseif($field['field_type'] === 'date') + + @elseif($field['field_type'] === 'select') + @if(isset($field['multiple']) && $field['multiple'] === true) + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + + @endif + @endforeach + @endif + + @else + + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + @endif + @else +

{{ $field['name'] }}: Field type "{{ $field['field_type'] }}" not yet supported

+ @endif +
+ @endforeach + @endif + +
+ + Save Configuration +
+
+
+
+

Settings

@@ -472,6 +670,85 @@ HTML; name="name" autofocus/> + @php + $authorField = null; + if (isset($configuration_template['custom_fields'])) { + foreach ($configuration_template['custom_fields'] as $field) { + if ($field['field_type'] === 'author_bio') { + $authorField = $field; + break; + } + } + } + @endphp + + @if($authorField) +
+
+ {{ $authorField['description'] }} +
+ + @if(isset($authorField['github_url']) || isset($authorField['learn_more_url']) || isset($authorField['email_address'])) +
+ @if(isset($authorField['github_url'])) + @php + $githubUrl = $authorField['github_url']; + $githubUsername = null; + + // Extract username from various GitHub URL formats + if (preg_match('/github\.com\/([^\/\?]+)/', $githubUrl, $matches)) { + $githubUsername = $matches[1]; + } + @endphp + @if($githubUsername)@endif + @endif + @if(isset($authorField['learn_more_url'])) + + Learn More + + @endif + + @if(isset($authorField['github_url'])) + + + @endif + + @if(isset($authorField['email_address'])) + + + @endif +
+ @endif +
+ @endif + + @if(isset($configuration_template['custom_fields']) && !empty($configuration_template['custom_fields'])) + @if($plugin->hasMissingRequiredConfigurationFields()) + + @endif +
+ + Configuration + +
+ @endif
@@ -482,15 +759,13 @@ HTML; @if($data_strategy === 'polling')
- - - - + class="block w-full" type="text" name="polling_url" autofocus> + + Fetch data now +
@@ -533,6 +808,7 @@ HTML;
-
-

Send JSON payload with key merge_variables to the webhook URL. The payload - will be merged with the plugin data.

-
@elseif($data_strategy === 'static') -
-

Enter static JSON data in the Data Payload field.

-
+ Enter static JSON data in the Data Payload field. @endif
- Save + Save
@@ -627,6 +897,8 @@ HTML;
+ + @script