diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index c4b45c8..6f5d88b 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; @@ -45,7 +44,6 @@ class Plugin extends Model 'no_bleed' => 'boolean', 'dark_mode' => 'boolean', 'preferred_renderer' => 'string', - 'plugin_type' => 'string', ]; protected static function boot() @@ -135,11 +133,6 @@ class Plugin extends Model public function isDataStale(): bool { - // Image webhook plugins don't use data staleness - images are pushed directly - if ($this->plugin_type === 'image_webhook') { - return false; - } - if ($this->data_strategy === 'webhook') { // Treat as stale if any webhook event has occurred in the past hour return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour()); @@ -454,10 +447,6 @@ 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.'); - } - if ($this->render_markup) { $renderedContent = ''; diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index b8269a3..fcd5f12 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -280,10 +280,6 @@ class ImageGenerationService public static function resetIfNotCacheable(?Plugin $plugin): void { if ($plugin?->id) { - // Image webhook plugins have finalized images that shouldn't be reset - if ($plugin->plugin_type === 'image_webhook') { - return; - } // Check if any devices have custom dimensions or use non-standard DeviceModels $hasCustomDimensions = Device::query() ->where(function ($query): void { diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php index 10a1580..a2d2e65 100644 --- a/database/factories/PluginFactory.php +++ b/database/factories/PluginFactory.php @@ -29,24 +29,8 @@ class PluginFactory extends Factory 'icon_url' => null, 'flux_icon_name' => null, 'author_name' => $this->faker->name(), - 'plugin_type' => 'recipe', 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]; } - - /** - * Indicate that the plugin is an image webhook plugin. - */ - public function imageWebhook(): static - { - return $this->state(fn (array $attributes): array => [ - 'plugin_type' => 'image_webhook', - 'data_strategy' => 'static', - 'data_stale_minutes' => 60, - 'polling_url' => null, - 'polling_verb' => 'get', - 'name' => $this->faker->randomElement(['Camera Feed', 'Security Camera', 'Webcam', 'Image Stream']), - ]); - } } diff --git a/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php b/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php deleted file mode 100644 index 558fe2c..0000000 --- a/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php +++ /dev/null @@ -1,28 +0,0 @@ -string('plugin_type')->default('recipe')->after('uuid'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('plugins', function (Blueprint $table): void { - $table->dropColumn('plugin_type'); - }); - } -}; diff --git a/resources/views/livewire/plugins/image-webhook-instance.blade.php b/resources/views/livewire/plugins/image-webhook-instance.blade.php deleted file mode 100644 index e4ad9df..0000000 --- a/resources/views/livewire/plugins/image-webhook-instance.blade.php +++ /dev/null @@ -1,298 +0,0 @@ -user()->plugins->contains($this->plugin), 403); - abort_unless($this->plugin->plugin_type === 'image_webhook', 404); - - $this->name = $this->plugin->name; - } - - protected array $rules = [ - 'name' => 'required|string|max:255', - 'checked_devices' => 'array', - 'device_playlist_names' => 'array', - 'device_playlists' => 'array', - 'device_weekdays' => 'array', - 'device_active_from' => 'array', - 'device_active_until' => 'array', - ]; - - public function updateName(): void - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - $this->validate(['name' => 'required|string|max:255']); - $this->plugin->update(['name' => $this->name]); - } - - - public function addToPlaylist() - { - $this->validate([ - 'checked_devices' => 'required|array|min:1', - ]); - - foreach ($this->checked_devices as $deviceId) { - if (!isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) { - $this->addError('device_playlists.' . $deviceId, 'Please select a playlist for each device.'); - return; - } - - if ($this->device_playlists[$deviceId] === 'new') { - if (!isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) { - $this->addError('device_playlist_names.' . $deviceId, 'Playlist name is required when creating a new playlist.'); - return; - } - } - } - - foreach ($this->checked_devices as $deviceId) { - $playlist = null; - - if ($this->device_playlists[$deviceId] === 'new') { - $playlist = \App\Models\Playlist::create([ - 'device_id' => $deviceId, - 'name' => $this->device_playlist_names[$deviceId], - 'weekdays' => !empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null, - 'active_from' => $this->device_active_from[$deviceId] ?? null, - 'active_until' => $this->device_active_until[$deviceId] ?? null, - ]); - } else { - $playlist = \App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]); - } - - $maxOrder = $playlist->items()->max('order') ?? 0; - - // Image webhook plugins only support full layout - $playlist->items()->create([ - 'plugin_id' => $this->plugin->id, - 'order' => $maxOrder + 1, - ]); - } - - $this->reset([ - 'checked_devices', - 'device_playlists', - 'device_playlist_names', - 'device_weekdays', - 'device_active_from', - 'device_active_until', - ]); - Flux::modal('add-to-playlist')->close(); - } - - public function getDevicePlaylists($deviceId) - { - return \App\Models\Playlist::where('device_id', $deviceId)->get(); - } - - public function hasAnyPlaylistSelected(): bool - { - foreach ($this->checked_devices as $deviceId) { - if (isset($this->device_playlists[$deviceId]) && !empty($this->device_playlists[$deviceId])) { - return true; - } - } - return false; - } - - public function deletePlugin(): void - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - $this->plugin->delete(); - $this->redirect(route('plugins.image-webhook')); - } - - public function getImagePath(): ?string - { - if (!$this->plugin->current_image) { - return null; - } - - $extensions = ['png', 'bmp']; - foreach ($extensions as $ext) { - $path = 'images/generated/'.$this->plugin->current_image.'.'.$ext; - if (\Illuminate\Support\Facades\Storage::disk('public')->exists($path)) { - return $path; - } - } - - return null; - } -}; -?> - -
-
-
-

Image Webhook – {{$plugin->name}}

- - - - Add to Playlist - - - - - - - Delete Instance - - - - -
- - -
-
- Add to Playlist -
- -
- -
- - @foreach(auth()->user()->devices as $device) - - @endforeach - -
- - @if(count($checked_devices) > 0) - -
- @foreach($checked_devices as $deviceId) - @php - $device = auth()->user()->devices->find($deviceId); - @endphp -
-
- {{ $device->name }} -
- -
- - - @foreach($this->getDevicePlaylists($deviceId) as $playlist) - - @endforeach - - -
- - @if(isset($device_playlists[$deviceId]) && $device_playlists[$deviceId] === 'new') -
-
- -
-
- - - - - - - - - -
-
-
- -
-
- -
-
-
- @endif -
- @endforeach -
- @endif - - -
- - Add to Playlist -
- -
-
- - -
- Delete {{ $plugin->name }}? -

This will also remove this instance from your playlists.

-
- -
- - - Cancel - - Delete instance -
-
- -
-
-
-
- -
- -
- - Save -
-
- -
- Webhook URL - - POST an image (PNG or BMP) to this URL to update the displayed image. - - - Images must be posted in a format that can directly be read by the device. You need to take care of image format, dithering, and bit-depth. Check device logs if the image is not shown. - - -
-
- -
-
- Current Image - @if($this->getImagePath()) - {{ $plugin->name }} - @else - - No image uploaded yet. POST an image to the webhook URL to get started. - - @endif -
-
-
-
-
- diff --git a/resources/views/livewire/plugins/image-webhook.blade.php b/resources/views/livewire/plugins/image-webhook.blade.php deleted file mode 100644 index 3161443..0000000 --- a/resources/views/livewire/plugins/image-webhook.blade.php +++ /dev/null @@ -1,163 +0,0 @@ - 'required|string|max:255', - ]; - - public function mount(): void - { - $this->refreshInstances(); - } - - public function refreshInstances(): void - { - $this->instances = auth()->user() - ->plugins() - ->where('plugin_type', 'image_webhook') - ->orderBy('created_at', 'desc') - ->get() - ->toArray(); - } - - public function createInstance(): void - { - abort_unless(auth()->user() !== null, 403); - $this->validate(); - - Plugin::create([ - 'uuid' => Str::uuid(), - 'user_id' => auth()->id(), - 'name' => $this->name, - 'plugin_type' => 'image_webhook', - 'data_strategy' => 'static', // Not used for image_webhook, but required - 'data_stale_minutes' => 60, // Not used for image_webhook, but required - ]); - - $this->reset(['name']); - $this->refreshInstances(); - - Flux::modal('create-instance')->close(); - } - - public function deleteInstance(int $pluginId): void - { - abort_unless(auth()->user() !== null, 403); - - $plugin = Plugin::where('id', $pluginId) - ->where('user_id', auth()->id()) - ->where('plugin_type', 'image_webhook') - ->firstOrFail(); - - $plugin->delete(); - $this->refreshInstances(); - } -}; -?> - -
-
-
-

Image Webhook - Plugin -

- - Create Instance - -
- - -
-
- Create Image Webhook Instance - Create a new instance that accepts images via webhook -
- -
-
- -
- -
- - Create Instance -
-
-
-
- - @if(empty($instances)) -
- - No instances yet - Create your first Image Webhook instance to get started. - -
- @else - - - - - - - - - - @foreach($instances as $instance) - - - - - @endforeach - -
-
Name
-
-
Actions
-
- {{ $instance['name'] }} - -
- - - - - - - - -
-
- @endif - - @foreach($instances as $instance) - -
- Delete {{ $instance['name'] }}? -

This will also remove this instance from your playlists.

-
- -
- - - Cancel - - Delete instance -
-
- @endforeach -
-
- diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 4347aaf..469365c 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -26,8 +26,6 @@ new class extends Component { ['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'], 'api' => ['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'], - 'image-webhook' => - ['name' => 'Image Webhook', 'flux_icon_name' => 'photo', 'detail_view_route' => 'plugins.image-webhook'], ]; protected $rules = [ @@ -42,12 +40,7 @@ new class extends Component { public function refreshPlugins(): void { - // Only show recipe plugins in the main list (image_webhook has its own management page) - $userPlugins = auth()->user()?->plugins() - ->where('plugin_type', 'recipe') - ->get() - ->makeHidden(['render_markup', 'data_payload']) - ->toArray(); + $userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray(); $allPlugins = array_merge($this->native_plugins, $userPlugins ?? []); $allPlugins = array_values($allPlugins); $allPlugins = $this->sortPlugins($allPlugins); diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index bda8221..ec53aae 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -976,8 +976,6 @@ HTML; wire:model.defer="multiValues.{{ $fieldKey }}.{{ $index }}" :placeholder="$field['placeholder'] ?? 'Value...'" class="flex-1" - pattern="[^,]*" - title="Commas are not allowed in this field" /> @if(count($multiValues[$fieldKey]) > 1) diff --git a/routes/api.php b/routes/api.php index 5700a43..b1d08b4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -549,91 +549,6 @@ Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) { return response()->json(['message' => 'Data updated successfully']); })->name('api.custom_plugins.webhook'); -Route::post('plugin_settings/{uuid}/image', function (Request $request, string $uuid) { - $plugin = Plugin::where('uuid', $uuid)->firstOrFail(); - - // Check if plugin is image_webhook type - if ($plugin->plugin_type !== 'image_webhook') { - return response()->json(['error' => 'Plugin is not an image webhook plugin'], 400); - } - - // Accept image from either multipart form or raw binary - $image = null; - $extension = null; - - if ($request->hasFile('image')) { - $file = $request->file('image'); - $extension = mb_strtolower($file->getClientOriginalExtension()); - $image = $file->get(); - } elseif ($request->has('image')) { - // Base64 encoded image - $imageData = $request->input('image'); - if (preg_match('/^data:image\/(\w+);base64,/', $imageData, $matches)) { - $extension = mb_strtolower($matches[1]); - $image = base64_decode(mb_substr($imageData, mb_strpos($imageData, ',') + 1)); - } else { - return response()->json(['error' => 'Invalid image format. Expected base64 data URI.'], 400); - } - } else { - // Try raw binary - $image = $request->getContent(); - $contentType = $request->header('Content-Type', ''); - $trimmedContent = mb_trim($image); - - // Check if content is empty or just empty JSON - if (empty($image) || $trimmedContent === '' || $trimmedContent === '{}') { - return response()->json(['error' => 'No image data provided'], 400); - } - - // If it's a JSON request without image field, return error - if (str_contains($contentType, 'application/json')) { - return response()->json(['error' => 'No image data provided'], 400); - } - - // Detect image type from content - $finfo = finfo_open(FILEINFO_MIME_TYPE); - $mimeType = finfo_buffer($finfo, $image); - finfo_close($finfo); - - $extension = match ($mimeType) { - 'image/png' => 'png', - 'image/bmp' => 'bmp', - default => null, - }; - - if (! $extension) { - return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400); - } - } - - // Validate extension - $allowedExtensions = ['png', 'bmp']; - if (! in_array($extension, $allowedExtensions)) { - return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400); - } - - // Generate a new UUID for each image upload to prevent device caching - $imageUuid = \Illuminate\Support\Str::uuid()->toString(); - $filename = $imageUuid.'.'.$extension; - $path = 'images/generated/'.$filename; - - // Save image to storage - Storage::disk('public')->put($path, $image); - - // Update plugin's current_image field with the new UUID - $plugin->update([ - 'current_image' => $imageUuid, - ]); - - // Clean up old images - ImageGenerationService::cleanupFolder(); - - return response()->json([ - 'message' => 'Image uploaded successfully', - 'image_url' => url('storage/'.$path), - ]); -})->name('api.plugin_settings.image'); - Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) { if (! $trmnlp_id || mb_trim($trmnlp_id) === '') { return response()->json([ diff --git a/routes/web.php b/routes/web.php index b3069bd..7b7868d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -31,8 +31,6 @@ Route::middleware(['auth'])->group(function () { Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe'); Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup'); Volt::route('plugins/api', 'plugins.api')->name('plugins.api'); - Volt::route('plugins/image-webhook', 'plugins.image-webhook')->name('plugins.image-webhook'); - Volt::route('plugins/image-webhook/{plugin}', 'plugins.image-webhook-instance')->name('plugins.image-webhook-instance'); Volt::route('playlists', 'playlists.index')->name('playlists.index'); Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) { diff --git a/tests/Feature/Api/ImageWebhookTest.php b/tests/Feature/Api/ImageWebhookTest.php deleted file mode 100644 index 121f90a..0000000 --- a/tests/Feature/Api/ImageWebhookTest.php +++ /dev/null @@ -1,196 +0,0 @@ -makeDirectory('/images/generated'); -}); - -test('can upload image to image webhook plugin via multipart form', function (): void { - $user = User::factory()->create(); - $plugin = Plugin::factory()->imageWebhook()->create([ - 'user_id' => $user->id, - ]); - - $image = UploadedFile::fake()->image('test.png', 800, 480); - - $response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [ - 'image' => $image, - ]); - - $response->assertOk() - ->assertJsonStructure([ - 'message', - 'image_url', - ]); - - $plugin->refresh(); - expect($plugin->current_image) - ->not->toBeNull() - ->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID - - // File should exist with the new UUID - Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png"); - - // Image URL should contain the new UUID - expect($response->json('image_url')) - ->toContain($plugin->current_image); -}); - -test('can upload image to image webhook plugin via raw binary', function (): void { - $user = User::factory()->create(); - $plugin = Plugin::factory()->imageWebhook()->create([ - 'user_id' => $user->id, - ]); - - // Create a simple PNG image binary - $pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); - - $response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [ - 'CONTENT_TYPE' => 'image/png', - ], $pngData); - - $response->assertOk() - ->assertJsonStructure([ - 'message', - 'image_url', - ]); - - $plugin->refresh(); - expect($plugin->current_image) - ->not->toBeNull() - ->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID - - // File should exist with the new UUID - Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png"); - - // Image URL should contain the new UUID - expect($response->json('image_url')) - ->toContain($plugin->current_image); -}); - -test('can upload image to image webhook plugin via base64 data URI', function (): void { - $user = User::factory()->create(); - $plugin = Plugin::factory()->imageWebhook()->create([ - 'user_id' => $user->id, - ]); - - // Create a simple PNG image as base64 data URI - $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; - - $response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", [ - 'image' => $base64Image, - ]); - - $response->assertOk() - ->assertJsonStructure([ - 'message', - 'image_url', - ]); - - $plugin->refresh(); - expect($plugin->current_image) - ->not->toBeNull() - ->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID - - // File should exist with the new UUID - Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png"); - - // Image URL should contain the new UUID - expect($response->json('image_url')) - ->toContain($plugin->current_image); -}); - -test('returns 400 for non-image-webhook plugin', function (): void { - $user = User::factory()->create(); - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'plugin_type' => 'recipe', - ]); - - $image = UploadedFile::fake()->image('test.png', 800, 480); - - $response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [ - 'image' => $image, - ]); - - $response->assertStatus(400) - ->assertJson(['error' => 'Plugin is not an image webhook plugin']); -}); - -test('returns 404 for non-existent plugin', function (): void { - $image = UploadedFile::fake()->image('test.png', 800, 480); - - $response = $this->post('/api/plugin_settings/'.Str::uuid().'/image', [ - 'image' => $image, - ]); - - $response->assertNotFound(); -}); - -test('returns 400 for unsupported image format', function (): void { - $user = User::factory()->create(); - $plugin = Plugin::factory()->imageWebhook()->create([ - 'user_id' => $user->id, - ]); - - // Create a fake GIF file (not supported) - $gifData = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); - - $response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [ - 'CONTENT_TYPE' => 'image/gif', - ], $gifData); - - $response->assertStatus(400) - ->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']); -}); - -test('returns 400 for JPG image format', function (): void { - $user = User::factory()->create(); - $plugin = Plugin::factory()->imageWebhook()->create([ - 'user_id' => $user->id, - ]); - - // Create a fake JPG file (not supported) - $jpgData = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A'); - - $response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [ - 'CONTENT_TYPE' => 'image/jpeg', - ], $jpgData); - - $response->assertStatus(400) - ->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']); -}); - -test('returns 400 when no image data provided', function (): void { - $user = User::factory()->create(); - $plugin = Plugin::factory()->imageWebhook()->create([ - 'user_id' => $user->id, - ]); - - $response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", []); - - $response->assertStatus(400) - ->assertJson(['error' => 'No image data provided']); -}); - -test('image webhook plugin isDataStale returns false', function (): void { - $plugin = Plugin::factory()->imageWebhook()->create(); - - expect($plugin->isDataStale())->toBeFalse(); -}); - -test('image webhook plugin factory creates correct plugin type', function (): void { - $plugin = Plugin::factory()->imageWebhook()->create(); - - expect($plugin) - ->plugin_type->toBe('image_webhook') - ->data_strategy->toBe('static'); -}); diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index 0847e36..b42668d 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -685,11 +685,11 @@ test('plugin render includes utc_offset and time_zone_iana in trmnl.user context * [Input, Expected Result, Forbidden String] */ dataset('xss_vectors', [ - 'standard_script' => ['Safe ', 'Safe ', '', 'Safe ', '