From 0c5041a8cabfd447ddf36adedeec05095548ccac Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sun, 21 Sep 2025 17:35:50 +0200 Subject: [PATCH] feat(catalog): add support recipes monorepos --- app/Models/PlaylistItem.php | 4 +- app/Models/Plugin.php | 6 +- app/Services/PluginImportService.php | 57 ++++- .../views/livewire/catalog/index.blade.php | 3 +- tests/Feature/PluginImportTest.php | 209 ++++++++++++++++++ 5 files changed, 268 insertions(+), 11 deletions(-) diff --git a/app/Models/PlaylistItem.php b/app/Models/PlaylistItem.php index 3040e39..28f6454 100644 --- a/app/Models/PlaylistItem.php +++ b/app/Models/PlaylistItem.php @@ -140,7 +140,7 @@ class PlaylistItem extends Model if (! $this->isMashup()) { return view('trmnl-layouts.single', [ 'colorDepth' => $device?->deviceModel?->color_depth, - 'deviceVariant' => $device?->deviceModel?->name ?? 'og', + 'deviceVariant' => $device?->deviceModel->name ?? 'og', 'scaleLevel' => $device?->deviceModel?->scale_level, 'slot' => $this->plugin instanceof Plugin ? $this->plugin->render('full', false) @@ -164,7 +164,7 @@ class PlaylistItem extends Model return view('trmnl-layouts.mashup', [ 'colorDepth' => $device?->deviceModel?->color_depth, - 'deviceVariant' => $device?->deviceModel?->name ?? 'og', + 'deviceVariant' => $device?->deviceModel->name ?? 'og', 'scaleLevel' => $device?->deviceModel?->scale_level, 'mashupLayout' => $this->getMashupLayoutType(), 'slot' => implode('', $pluginMarkups), diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 8f0ec75..7c6d2c1 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -346,7 +346,7 @@ class Plugin extends Model if ($size === 'full') { return view('trmnl-layouts.single', [ 'colorDepth' => $device?->deviceModel?->color_depth, - 'deviceVariant' => $device?->deviceModel?->name ?? 'og', + 'deviceVariant' => $device?->deviceModel->name ?? 'og', 'scaleLevel' => $device?->deviceModel?->scale_level, 'slot' => $renderedContent, ])->render(); @@ -354,7 +354,7 @@ class Plugin extends Model return view('trmnl-layouts.mashup', [ 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size), 'colorDepth' => $device?->deviceModel?->color_depth, - 'deviceVariant' => $device?->deviceModel?->name ?? 'og', + 'deviceVariant' => $device?->deviceModel->name ?? 'og', 'scaleLevel' => $device?->deviceModel?->scale_level, 'slot' => $renderedContent, ])->render(); @@ -368,7 +368,7 @@ class Plugin extends Model if ($standalone) { return view('trmnl-layouts.single', [ 'colorDepth' => $device?->deviceModel?->color_depth, - 'deviceVariant' => $device?->deviceModel?->name ?? 'og', + 'deviceVariant' => $device?->deviceModel->name ?? 'og', 'scaleLevel' => $device?->deviceModel?->scale_level, 'slot' => view($this->render_markup_view, [ 'size' => $size, diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index 29b5688..c409d99 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -22,11 +22,12 @@ class PluginImportService * * @param UploadedFile $zipFile The uploaded ZIP file * @param User $user The user importing the plugin + * @param string|null $zipEntryPath Optional path to specific plugin in monorepo * @return Plugin The created plugin instance * * @throws Exception If the ZIP file is invalid or required files are missing */ - public function importFromZip(UploadedFile $zipFile, User $user): Plugin + public function importFromZip(UploadedFile $zipFile, User $user, ?string $zipEntryPath = null): Plugin { // Create a temporary directory using Laravel's temporary directory helper $tempDirName = 'temp/'.uniqid('plugin_import_', true); @@ -47,7 +48,7 @@ class PluginImportService $zip->close(); // Find the required files (settings.yml and full.liquid/full.blade.php) - $filePaths = $this->findRequiredFiles($tempDir); + $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); // Validate that we found the required files if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { @@ -138,11 +139,12 @@ class PluginImportService * * @param string $zipUrl The URL to the ZIP file * @param User $user The user importing the plugin + * @param string|null $zipEntryPath Optional path to specific plugin in monorepo * @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 + public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null): Plugin { // Download the ZIP file $response = Http::timeout(60)->get($zipUrl); @@ -171,7 +173,7 @@ class PluginImportService $zip->close(); // Find the required files (settings.yml and full.liquid/full.blade.php) - $filePaths = $this->findRequiredFiles($tempDir); + $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); // Validate that we found the required files if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { @@ -257,12 +259,57 @@ class PluginImportService } } - private function findRequiredFiles(string $tempDir): array + private function findRequiredFiles(string $tempDir, ?string $zipEntryPath = null): array { $settingsYamlPath = null; $fullLiquidPath = null; $sharedLiquidPath = null; + // If zipEntryPath is specified, look for files in that specific directory first + if ($zipEntryPath) { + $targetDir = $tempDir . '/' . $zipEntryPath; + if (File::exists($targetDir)) { + // Check if files are directly in the target directory + if (File::exists($targetDir . '/settings.yml')) { + $settingsYamlPath = $targetDir . '/settings.yml'; + + if (File::exists($targetDir . '/full.liquid')) { + $fullLiquidPath = $targetDir . '/full.liquid'; + } elseif (File::exists($targetDir . '/full.blade.php')) { + $fullLiquidPath = $targetDir . '/full.blade.php'; + } + + if (File::exists($targetDir . '/shared.liquid')) { + $sharedLiquidPath = $targetDir . '/shared.liquid'; + } + } + + // Check if files are in src subdirectory of target directory + if (!$settingsYamlPath && File::exists($targetDir . '/src/settings.yml')) { + $settingsYamlPath = $targetDir . '/src/settings.yml'; + + if (File::exists($targetDir . '/src/full.liquid')) { + $fullLiquidPath = $targetDir . '/src/full.liquid'; + } elseif (File::exists($targetDir . '/src/full.blade.php')) { + $fullLiquidPath = $targetDir . '/src/full.blade.php'; + } + + if (File::exists($targetDir . '/src/shared.liquid')) { + $sharedLiquidPath = $targetDir . '/src/shared.liquid'; + } + } + + // If we found the required files in the target directory, return them + if ($settingsYamlPath && $fullLiquidPath) { + return [ + 'settingsYamlPath' => $settingsYamlPath, + 'fullLiquidPath' => $fullLiquidPath, + 'sharedLiquidPath' => $sharedLiquidPath, + ]; + } + } + } + // First, check if files are directly in the src folder if (File::exists($tempDir.'/src/settings.yml')) { $settingsYamlPath = $tempDir.'/src/settings.yml'; diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 92bd5a9..5bdae10 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -53,6 +53,7 @@ new class extends Component { 'github' => Arr::get($plugin, 'author.github'), 'license' => Arr::get($plugin, 'license'), 'zip_url' => Arr::get($plugin, 'trmnlp.zip_url'), + 'zip_entry_path' => Arr::get($plugin, 'trmnlp.zip_entry_path'), 'repo_url' => Arr::get($plugin, 'trmnlp.repo'), 'logo_url' => Arr::get($plugin, 'logo_url'), 'screenshot_url' => Arr::get($plugin, 'screenshot_url'), @@ -82,7 +83,7 @@ new class extends Component { $this->installingPlugin = $pluginId; try { - $importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user()); + $importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user(), $plugin['zip_entry_path'] ?? null); $this->dispatch('plugin-installed'); Flux::modal('import-from-catalog')->close(); diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php index 25325d2..9a7293d 100644 --- a/tests/Feature/PluginImportTest.php +++ b/tests/Feature/PluginImportTest.php @@ -6,6 +6,7 @@ use App\Models\Plugin; use App\Models\User; use App\Services\PluginImportService; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; beforeEach(function () { @@ -132,6 +133,214 @@ it('handles blade markup language correctly', function () { expect($plugin->markup_language)->toBe('blade'); }); +it('imports plugin from monorepo with zip_entry_path parameter', function () { + $user = User::factory()->create(); + + // Create a mock ZIP file with plugin in a subdirectory + $zipContent = createMockZipFile([ + 'example-plugin/settings.yml' => getValidSettingsYaml(), + 'example-plugin/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('monorepo.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->user_id)->toBe($user->id) + ->and($plugin->name)->toBe('Test Plugin'); +}); + +it('imports plugin from monorepo with src subdirectory', function () { + $user = User::factory()->create(); + + // Create a mock ZIP file with plugin in a subdirectory with src folder + $zipContent = createMockZipFile([ + 'example-plugin/src/settings.yml' => getValidSettingsYaml(), + 'example-plugin/src/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('monorepo.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->user_id)->toBe($user->id) + ->and($plugin->name)->toBe('Test Plugin'); +}); + +it('imports plugin from monorepo with shared.liquid in subdirectory', function () { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'example-plugin/settings.yml' => getValidSettingsYaml(), + 'example-plugin/full.liquid' => getValidFullLiquid(), + 'example-plugin/shared.liquid' => '{% comment %}Monorepo shared styles{% endcomment %}', + ]); + + $zipFile = UploadedFile::fake()->createWithContent('monorepo.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin->render_markup)->toContain('{% comment %}Monorepo shared styles{% endcomment %}') + ->and($plugin->render_markup)->toContain('
'); +}); + +it('imports plugin from URL with zip_entry_path parameter', function () { + $user = User::factory()->create(); + + // Create a mock ZIP file with plugin in a subdirectory + $zipContent = createMockZipFile([ + 'example-plugin/settings.yml' => getValidSettingsYaml(), + 'example-plugin/full.liquid' => getValidFullLiquid(), + ]); + + // Mock the HTTP response + Http::fake([ + 'https://github.com/example/repo/archive/refs/heads/main.zip' => Http::response($zipContent, 200), + ]); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromUrl( + 'https://github.com/example/repo/archive/refs/heads/main.zip', + $user, + 'example-plugin' + ); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->user_id)->toBe($user->id) + ->and($plugin->name)->toBe('Test Plugin'); + + Http::assertSent(function ($request) { + return $request->url() === 'https://github.com/example/repo/archive/refs/heads/main.zip'; + }); +}); + +it('imports plugin from URL with zip_entry_path and src subdirectory', function () { + $user = User::factory()->create(); + + // Create a mock ZIP file with plugin in a subdirectory with src folder + $zipContent = createMockZipFile([ + 'example-plugin/src/settings.yml' => getValidSettingsYaml(), + 'example-plugin/src/full.liquid' => getValidFullLiquid(), + ]); + + // Mock the HTTP response + Http::fake([ + 'https://github.com/example/repo/archive/refs/heads/main.zip' => Http::response($zipContent, 200), + ]); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromUrl( + 'https://github.com/example/repo/archive/refs/heads/main.zip', + $user, + 'example-plugin' + ); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->user_id)->toBe($user->id) + ->and($plugin->name)->toBe('Test Plugin'); +}); + +it('imports plugin from GitHub monorepo with repository-named directory', function () { + $user = User::factory()->create(); + + // Create a mock ZIP file that simulates GitHub's ZIP structure with repository-named directory + $zipContent = createMockZipFile([ + 'example-repo-main/example-plugin/src/settings.yml' => getValidSettingsYaml(), + 'example-repo-main/example-plugin/src/full.liquid' => getValidFullLiquid(), + 'example-repo-main/other-plugin/src/settings.yml' => "name: Other Plugin\nrefresh_interval: 60\nstrategy: static\npolling_verb: get\nstatic_data: '{}'\ncustom_fields: []", + 'example-repo-main/other-plugin/src/full.liquid' => '
Other content
', + ]); + + // Mock the HTTP response + Http::fake([ + 'https://github.com/example/repo/archive/refs/heads/main.zip' => Http::response($zipContent, 200), + ]); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromUrl( + 'https://github.com/example/repo/archive/refs/heads/main.zip', + $user, + 'example-plugin' + ); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->user_id)->toBe($user->id) + ->and($plugin->name)->toBe('Test Plugin'); // Should be from example-plugin, not other-plugin +}); + +it('finds required files in simple ZIP structure', function () { + $user = User::factory()->create(); + + // Create a simple ZIP file with just one plugin + $zipContent = createMockZipFile([ + 'example-repo-main/example-plugin/src/settings.yml' => getValidSettingsYaml(), + 'example-repo-main/example-plugin/src/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('simple.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->user_id)->toBe($user->id) + ->and($plugin->name)->toBe('Test Plugin'); +}); + +it('finds required files in GitHub monorepo structure with zip_entry_path', function () { + $user = User::factory()->create(); + + // Create a mock ZIP file that simulates GitHub's ZIP structure + $zipContent = createMockZipFile([ + 'example-repo-main/example-plugin/src/settings.yml' => getValidSettingsYaml(), + 'example-repo-main/example-plugin/src/full.liquid' => getValidFullLiquid(), + 'example-repo-main/other-plugin/src/settings.yml' => "name: Other Plugin\nrefresh_interval: 60\nstrategy: static\npolling_verb: get\nstatic_data: '{}'\ncustom_fields: []", + 'example-repo-main/other-plugin/src/full.liquid' => '
Other content
', + ]); + + $zipFile = UploadedFile::fake()->createWithContent('monorepo.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user, 'example-plugin'); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->user_id)->toBe($user->id) + ->and($plugin->name)->toBe('Test Plugin'); // Should be from example-plugin, not other-plugin +}); + +it('imports specific plugin from monorepo zip with zip_entry_path parameter', function () { + $user = User::factory()->create(); + + // Create a mock ZIP file with 2 plugins in a monorepo structure + $zipContent = createMockZipFile([ + 'example-plugin/settings.yml' => getValidSettingsYaml(), + 'example-plugin/full.liquid' => getValidFullLiquid(), + 'example-plugin/shared.liquid' => '{% comment %}Monorepo shared styles{% endcomment %}', + 'example-plugin2/settings.yml' => "name: Example Plugin 2\nrefresh_interval: 45\nstrategy: static\npolling_verb: get\nstatic_data: '{}'\ncustom_fields: []", + 'example-plugin2/full.liquid' => '
Plugin 2 content
', + 'example-plugin2/shared.liquid' => '{% comment %}Plugin 2 shared styles{% endcomment %}', + ]); + + $zipFile = UploadedFile::fake()->createWithContent('monorepo.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + + // This test will fail because importFromZip doesn't support zip_entry_path parameter yet + // The logic needs to be implemented to specify which plugin to import from the monorepo + $plugin = $pluginImportService->importFromZip($zipFile, $user, 'example-plugin2'); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->user_id)->toBe($user->id) + ->and($plugin->name)->toBe('Example Plugin 2') // Should import example-plugin2, not example-plugin + ->and($plugin->render_markup)->toContain('{% comment %}Plugin 2 shared styles{% endcomment %}') + ->and($plugin->render_markup)->toContain('
Plugin 2 content
'); +}); + // Helper methods function createMockZipFile(array $files): string {