diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index c4b45c8..2a3e6c5 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; @@ -455,7 +455,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) { @@ -606,4 +606,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/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 4482d5a..47b356a 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -370,6 +370,17 @@ HTML; } } + 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); @@ -427,6 +438,7 @@ HTML; + Duplicate Plugin Delete Plugin diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index 0847e36..2771f81 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -737,3 +737,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); +});