From eb78831568434fd20bc0708bf3af1e2417e4de08 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Wed, 27 Aug 2025 13:04:06 +0200 Subject: [PATCH] feat: initial implementation of recipe catalog --- app/Services/PluginImportService.php | 125 +++++++++++++++ config/app.php | 1 + .../views/livewire/catalog/index.blade.php | 148 ++++++++++++++++++ .../views/livewire/plugins/index.blade.php | 29 +++- tests/Feature/Livewire/Catalog/IndexTest.php | 102 ++++++++++++ tests/Feature/PluginImportTest.php | 2 +- 6 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 resources/views/livewire/catalog/index.blade.php create mode 100644 tests/Feature/Livewire/Catalog/IndexTest.php diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index dbd8ec8..29b5688 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -7,6 +7,7 @@ use App\Models\User; use Exception; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -48,6 +49,130 @@ class PluginImportService // 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 are missing.'); // full.blade.php + } + + // Parse settings.yml + $settingsYaml = File::get($filePaths['settingsYamlPath']); + $settings = Yaml::parse($settingsYaml); + + // 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; + } + + $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + + // 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'], + ]; + + $plugin_updated = isset($settings['id']) + && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists(); + // Create a new plugin + $plugin = Plugin::updateOrCreate( + [ + 'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + ], + [ + 'user_id' => $user->id, + 'name' => $settings['name'] ?? 'Imported Plugin', + 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + '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, + 'data_payload' => json_decode($settings['static_data'] ?? '{}', true), + ]); + + if (! $plugin_updated) { + // Extract default values from custom_fields and populate configuration + $configuration = []; + foreach ($settings['custom_fields'] as $field) { + if (isset($field['keyname']) && isset($field['default'])) { + $configuration[$field['keyname']] = $field['default']; + } + } + // set only if plugin is new + $plugin->update([ + 'configuration' => $configuration, + ]); + } + $plugin['trmnlp_yaml'] = $settingsYaml; + + return $plugin; + + } finally { + // Clean up temporary directory + Storage::deleteDirectory($tempDirName); + } + } + + /** + * Import a plugin from a ZIP URL + * + * @param string $zipUrl The URL to the ZIP file + * @param User $user The user importing the plugin + * @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): Plugin + { + // Download the ZIP file + $response = Http::timeout(60)->get($zipUrl); + + if (! $response->successful()) { + throw new Exception('Could not download the ZIP file from the provided URL.'); + } + + // Create a temporary file + $tempDirName = 'temp/'.uniqid('plugin_import_', true); + Storage::makeDirectory($tempDirName); + $tempDir = Storage::path($tempDirName); + $zipPath = $tempDir.'/plugin.zip'; + + // Save the downloaded content to a temporary file + File::put($zipPath, $response->body()); + + try { + // Extract the ZIP file using ZipArchive + $zip = new ZipArchive(); + if ($zip->open($zipPath) !== true) { + throw new Exception('Could not open the downloaded 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.'); diff --git a/config/app.php b/config/app.php index 98eaee9..73bcaaf 100644 --- a/config/app.php +++ b/config/app.php @@ -152,4 +152,5 @@ return [ 'version' => env('APP_VERSION', null), + 'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'), ]; diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php new file mode 100644 index 0000000..4725e68 --- /dev/null +++ b/resources/views/livewire/catalog/index.blade.php @@ -0,0 +1,148 @@ +loadCatalogPlugins(); + } + + private function loadCatalogPlugins(): void + { + $catalogUrl = config('app.catalog_url'); + + $this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) { + try { + $response = Http::get($catalogUrl); + $catalogContent = $response->body(); + $catalog = Yaml::parse($catalogContent); + + return collect($catalog)->map(function ($plugin, $key) { + return [ + 'id' => $key, + 'name' => $plugin['name'] ?? 'Unknown Plugin', + 'description' => $plugin['author_bio']['description'] ?? '', + 'author' => $plugin['author']['name'] ?? 'Unknown Author', + 'github' => $plugin['author']['github'] ?? null, + 'license' => $plugin['license'] ?? null, + 'zip_url' => $plugin['trmnlp']['zip_url'] ?? null, + 'repo_url' => $plugin['trmnlp']['repo'] ?? null, + 'logo_url' => $plugin['logo_url'] ?? null, + 'screenshot_url' => $plugin['screenshot_url'] ?? null, + 'learn_more_url' => $plugin['author_bio']['learn_more_url'] ?? null, + ]; + })->toArray(); + } catch (\Exception $e) { + Log::error('Failed to load catalog from URL: ' . $e->getMessage()); + return []; + } + }); + } + + public function installPlugin(string $pluginId, PluginImportService $pluginImportService): void + { + abort_unless(auth()->user() !== null, 403); + + $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); + + if (!$plugin || !$plugin['zip_url']) { + $this->addError('installation', 'Plugin not found or no download URL available.'); + return; + } + + $this->installingPlugin = $pluginId; + + try { + $importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user()); + + $this->dispatch('plugin-installed'); + Flux::modal('import-from-catalog')->close(); + + } catch (\Exception $e) { + $this->addError('installation', 'Error installing plugin: ' . $e->getMessage()); + } finally { + $this->installingPlugin = ''; + } + } +}; ?> + +
+ @if(empty($catalogPlugins)) +
+ + No plugins available + Catalog is empty +
+ @else +
+ @error('installation') + + @enderror + + @foreach($catalogPlugins as $plugin) +
+
+ @if($plugin['logo_url']) + {{ $plugin['name'] }} + @else +
+ +
+ @endif + +
+
+
+

{{ $plugin['name'] }}

+ @if ($plugin['github']) +

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

+ @endif +
+
+ @if($plugin['license']) + {{ $plugin['license'] }} + @endif + @if($plugin['repo_url']) + + + + @endif +
+
+ + @if($plugin['description']) +

{{ $plugin['description'] }}

+ @endif + +
+ + Install + + + @if($plugin['learn_more_url']) + + Learn More + + @endif +
+
+
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 9a5dd69..828e051 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -36,7 +36,7 @@ new class extends Component { 'polling_body' => 'nullable|string', ]; - private function refreshPlugins(): void + public function refreshPlugins(): void { $userPlugins = auth()->user()?->plugins?->map(function ($plugin) { return $plugin->toArray(); @@ -96,10 +96,8 @@ new class extends Component { $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()]); + $this->addError('zipFile', 'Error installing plugin: ' . $e->getMessage()); } } @@ -120,7 +118,10 @@ new class extends Component { - Import Recipe + Import Recipe Archive + + + Import from Catalog Seed Example Recipes @@ -167,7 +168,7 @@ new class extends Component {
- + .zip Archive - @error('zipFile') {{ $message }} @enderror + @error('zipFile') + + @enderror
@@ -186,6 +189,18 @@ new class extends Component {
+ +
+
+ Import from Catalog + Alpha + + Browse and install Recipes from the community. Add yours here. +
+ +
+
+
diff --git a/tests/Feature/Livewire/Catalog/IndexTest.php b/tests/Feature/Livewire/Catalog/IndexTest.php new file mode 100644 index 0000000..7defd78 --- /dev/null +++ b/tests/Feature/Livewire/Catalog/IndexTest.php @@ -0,0 +1,102 @@ + Http::response('', 200), + ]); + + $component = Volt::test('catalog.index'); + + $component->assertSee('No plugins available'); +}); + +it('loads plugins from catalog URL', function () { + // Clear cache first to ensure fresh data + Cache::forget('catalog_plugins'); + + // Mock the HTTP response for the catalog URL + $catalogData = [ + 'test-plugin' => [ + 'name' => 'Test Plugin', + 'author' => ['name' => 'Test Author', 'github' => 'testuser'], + 'author_bio' => [ + 'description' => 'A test plugin', + 'learn_more_url' => 'https://example.com', + ], + 'license' => 'MIT', + 'trmnlp' => [ + 'zip_url' => 'https://example.com/plugin.zip', + ], + 'logo_url' => 'https://example.com/logo.png', + ], + ]; + + $yamlContent = Yaml::dump($catalogData); + + // Override the default mock with specific data + Http::fake([ + config('app.catalog_url') => Http::response($yamlContent, 200), + ]); + + $component = Volt::test('catalog.index'); + + $component->assertSee('Test Plugin'); + $component->assertSee('testuser'); + $component->assertSee('A test plugin'); + $component->assertSee('MIT'); +}); + +it('shows error when plugin not found', function () { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('catalog.index'); + + $component->call('installPlugin', 'non-existent-plugin'); + + // The component should dispatch an error notification + $component->assertHasErrors(); +}); + +it('shows error when zip_url is missing', function () { + $user = User::factory()->create(); + + // Mock the HTTP response for the catalog URL without zip_url + $catalogData = [ + 'test-plugin' => [ + 'name' => 'Test Plugin', + 'author' => ['name' => 'Test Author'], + 'author_bio' => ['description' => 'A test plugin'], + 'license' => 'MIT', + 'trmnlp' => [], + ], + ]; + + $yamlContent = Yaml::dump($catalogData); + + Http::fake([ + config('app.catalog_url') => Http::response($yamlContent, 200), + ]); + + $this->actingAs($user); + + $component = Volt::test('catalog.index'); + + $component->call('installPlugin', 'test-plugin'); + + // The component should dispatch an error notification + $component->assertHasErrors(); + +}); diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php index 9aeda6e..25325d2 100644 --- a/tests/Feature/PluginImportTest.php +++ b/tests/Feature/PluginImportTest.php @@ -94,7 +94,7 @@ it('throws exception for missing required files', function () { $pluginImportService = new PluginImportService(); expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) - ->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.'); + ->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); }); it('sets default values when settings are missing', function () {