diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..ea30195 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "./artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc new file mode 100644 index 0000000..9464f06 --- /dev/null +++ b/.cursor/rules/laravel-boost.mdc @@ -0,0 +1,534 @@ +--- +alwaysApply: true +--- + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.24 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v3 +- livewire/volt (VOLT) - v1 +- larastan/larastan (LARASTAN) - v3 +- laravel/pint (PINT) - v1 +- pestphp/pest (PEST) - v3 +- tailwindcss (TAILWINDCSS) - v4 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== fluxui-free/core rules === + +## Flux UI Free + +- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. +- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. +- You should use Flux UI components when available. +- Fallback to standard Blade components if Flux is unavailable. +- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. +- Flux UI components look like this: + + + + + + +### Available Components +This is correct as of Boost installation, but there may be additional components within the codebase. + + +avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip + + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== volt/core rules === + +## Livewire Volt + +- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it. +- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]` +- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file +- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array ( +)])) + + + +### Volt Class Based Component Example +To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: + + + +use Livewire\Volt\Component; + +new class extends Component { + public $count = 0; + + public function increment() + { + $this->count++; + } +} ?> + +
+

{{ $count }}

+ +
+
+ + +### Testing Volt & Volt Components +- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`. + + +use Livewire\Volt\Volt; + +test('counter increments', function () { + Volt::test('counter') + ->assertSee('Count: 0') + ->call('increment') + ->assertSee('Count: 1'); +}); + + + + +declare(strict_types=1); + +use App\Models\{User, Product}; +use Livewire\Volt\Volt; + +test('product form creates product', function () { + $user = User::factory()->create(); + + Volt::test('pages.products.create') + ->actingAs($user) + ->set('form.name', 'Test Product') + ->set('form.description', 'Test Description') + ->set('form.price', 99.99) + ->call('create') + ->assertHasNoErrors(); + + expect(Product::where('name', 'Test Product')->exists())->toBeTrue(); +}); + + + +### Common Patterns + + + + null, 'search' => '']); + +$products = computed(fn() => Product::when($this->search, + fn($q) => $q->where('name', 'like', "%{$this->search}%") +)->get()); + +$edit = fn(Product $product) => $this->editing = $product->id; +$delete = fn(Product $product) => $product->delete(); + +?> + + + + + + + + + + + + + + + Save + Saving... + + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/.devcontainer/cli/Dockerfile b/.devcontainer/cli/Dockerfile index ab13330..0317097 100644 --- a/.devcontainer/cli/Dockerfile +++ b/.devcontainer/cli/Dockerfile @@ -9,8 +9,7 @@ RUN apk add --no-cache composer # Add Chromium and Image Magick for puppeteer. RUN apk add --no-cache \ imagemagick-dev \ - chromium \ - libzip-dev + chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_DOCKER=1 @@ -20,7 +19,7 @@ RUN chmod 777 /usr/src/php/ext/imagick RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 # Install PHP extensions -RUN docker-php-ext-install imagick zip +RUN docker-php-ext-install imagick # Composer uses its php binary, but we want it to use the container's one RUN rm -f /usr/bin/php84 diff --git a/.devcontainer/fpm/Dockerfile b/.devcontainer/fpm/Dockerfile index 3e658b6..8c585c8 100644 --- a/.devcontainer/fpm/Dockerfile +++ b/.devcontainer/fpm/Dockerfile @@ -14,8 +14,7 @@ RUN apk add --no-cache \ nodejs \ npm \ imagemagick-dev \ - chromium \ - libzip-dev + chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_DOCKER=1 @@ -25,7 +24,7 @@ RUN chmod 777 /usr/src/php/ext/imagick RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 # Install PHP extensions -RUN docker-php-ext-install imagick zip +RUN docker-php-ext-install imagick RUN rm -f /usr/bin/php84 RUN ln -s /usr/local/bin/php /usr/bin/php84 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a331541 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,531 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.24 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v3 +- livewire/volt (VOLT) - v1 +- larastan/larastan (LARASTAN) - v3 +- laravel/pint (PINT) - v1 +- pestphp/pest (PEST) - v3 +- tailwindcss (TAILWINDCSS) - v4 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== fluxui-free/core rules === + +## Flux UI Free + +- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. +- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. +- You should use Flux UI components when available. +- Fallback to standard Blade components if Flux is unavailable. +- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. +- Flux UI components look like this: + + + + + + +### Available Components +This is correct as of Boost installation, but there may be additional components within the codebase. + + +avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip + + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== volt/core rules === + +## Livewire Volt + +- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it. +- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]` +- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file +- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array ( +)])) + + + +### Volt Class Based Component Example +To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: + + + +use Livewire\Volt\Component; + +new class extends Component { + public $count = 0; + + public function increment() + { + $this->count++; + } +} ?> + +
+

{{ $count }}

+ +
+
+ + +### Testing Volt & Volt Components +- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`. + + +use Livewire\Volt\Volt; + +test('counter increments', function () { + Volt::test('counter') + ->assertSee('Count: 0') + ->call('increment') + ->assertSee('Count: 1'); +}); + + + + +declare(strict_types=1); + +use App\Models\{User, Product}; +use Livewire\Volt\Volt; + +test('product form creates product', function () { + $user = User::factory()->create(); + + Volt::test('pages.products.create') + ->actingAs($user) + ->set('form.name', 'Test Product') + ->set('form.description', 'Test Description') + ->set('form.price', 99.99) + ->call('create') + ->assertHasNoErrors(); + + expect(Product::where('name', 'Test Product')->exists())->toBeTrue(); +}); + + + +### Common Patterns + + + + null, 'search' => '']); + +$products = computed(fn() => Product::when($this->search, + fn($q) => $q->where('name', 'like', "%{$this->search}%") +)->get()); + +$edit = fn(Product $product) => $this->editing = $product->id; +$delete = fn(Product $product) => $product->delete(); + +?> + + + + + + + + + + + + + + + Save + Saving... + + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index a4ff129..048db51 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -42,7 +42,8 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=semver,pattern={{version}} + type=ref,event=tag + latest - name: Build and push Docker image uses: docker/build-push-action@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78e4fbb..fd03705 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.4 + php-version: 8.3 coverage: xdebug - name: Setup Node diff --git a/.gitignore b/.gitignore index 0eb46d3..3a2ae5a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,17 +23,3 @@ yarn-error.log /.zed /database/seeders/PersonalDeviceSeeder.php /.junie/mcp/mcp.json -/.cursor/mcp.json -/.cursor/rules/laravel-boost.mdc -/.github/copilot-instructions.md -/.junie/guidelines.md -/CLAUDE.md -/.mcp.json -/.ai -.DS_Store -/boost.json -/.gemini -/GEMINI.md -/.claude -/AGENTS.md -/opencode.json diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..a331541 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,531 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.24 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v3 +- livewire/volt (VOLT) - v1 +- larastan/larastan (LARASTAN) - v3 +- laravel/pint (PINT) - v1 +- pestphp/pest (PEST) - v3 +- tailwindcss (TAILWINDCSS) - v4 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== fluxui-free/core rules === + +## Flux UI Free + +- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. +- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. +- You should use Flux UI components when available. +- Fallback to standard Blade components if Flux is unavailable. +- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. +- Flux UI components look like this: + + + + + + +### Available Components +This is correct as of Boost installation, but there may be additional components within the codebase. + + +avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip + + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== volt/core rules === + +## Livewire Volt + +- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it. +- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]` +- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file +- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array ( +)])) + + + +### Volt Class Based Component Example +To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: + + + +use Livewire\Volt\Component; + +new class extends Component { + public $count = 0; + + public function increment() + { + $this->count++; + } +} ?> + +
+

{{ $count }}

+ +
+
+ + +### Testing Volt & Volt Components +- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`. + + +use Livewire\Volt\Volt; + +test('counter increments', function () { + Volt::test('counter') + ->assertSee('Count: 0') + ->call('increment') + ->assertSee('Count: 1'); +}); + + + + +declare(strict_types=1); + +use App\Models\{User, Product}; +use Livewire\Volt\Volt; + +test('product form creates product', function () { + $user = User::factory()->create(); + + Volt::test('pages.products.create') + ->actingAs($user) + ->set('form.name', 'Test Product') + ->set('form.description', 'Test Description') + ->set('form.price', 99.99) + ->call('create') + ->assertHasNoErrors(); + + expect(Product::where('name', 'Test Product')->exists())->toBeTrue(); +}); + + + +### Common Patterns + + + + null, 'search' => '']); + +$products = computed(fn() => Product::when($this->search, + fn($q) => $q->where('name', 'like', "%{$this->search}%") +)->get()); + +$edit = fn(Product $product) => $this->editing = $product->id; +$delete = fn(Product $product) => $product->delete(); + +?> + + + + + + + + + + + + + + + Save + Saving... + + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ea30195 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "./artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a331541 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,531 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.24 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v3 +- livewire/volt (VOLT) - v1 +- larastan/larastan (LARASTAN) - v3 +- laravel/pint (PINT) - v1 +- pestphp/pest (PEST) - v3 +- tailwindcss (TAILWINDCSS) - v4 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== fluxui-free/core rules === + +## Flux UI Free + +- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. +- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. +- You should use Flux UI components when available. +- Fallback to standard Blade components if Flux is unavailable. +- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. +- Flux UI components look like this: + + + + + + +### Available Components +This is correct as of Boost installation, but there may be additional components within the codebase. + + +avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip + + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== volt/core rules === + +## Livewire Volt + +- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it. +- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]` +- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file +- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array ( +)])) + + + +### Volt Class Based Component Example +To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: + + + +use Livewire\Volt\Component; + +new class extends Component { + public $count = 0; + + public function increment() + { + $this->count++; + } +} ?> + +
+

{{ $count }}

+ +
+
+ + +### Testing Volt & Volt Components +- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`. + + +use Livewire\Volt\Volt; + +test('counter increments', function () { + Volt::test('counter') + ->assertSee('Count: 0') + ->call('increment') + ->assertSee('Count: 1'); +}); + + + + +declare(strict_types=1); + +use App\Models\{User, Product}; +use Livewire\Volt\Volt; + +test('product form creates product', function () { + $user = User::factory()->create(); + + Volt::test('pages.products.create') + ->actingAs($user) + ->set('form.name', 'Test Product') + ->set('form.description', 'Test Description') + ->set('form.price', 99.99) + ->call('create') + ->assertHasNoErrors(); + + expect(Product::where('name', 'Test Product')->exists())->toBeTrue(); +}); + + + +### Common Patterns + + + + null, 'search' => '']); + +$products = computed(fn() => Product::when($this->search, + fn($q) => $q->where('name', 'like', "%{$this->search}%") +)->get()); + +$edit = fn(Product $product) => $this->editing = $product->id; +$delete = fn(Product $product) => $product->delete(); + +?> + + + + + + + + + + + + + + + Save + Saving... + + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2d761ed..57a919f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ ######################## # Base Image ######################## -FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base +FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium AS base LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel LABEL org.opencontainers.image.description="TRMNL BYOS Laravel" @@ -12,14 +12,9 @@ ENV APP_VERSION=${APP_VERSION} ENV AUTORUN_ENABLED="true" -# Mark trmnl-liquid-cli as installed -ENV TRMNL_LIQUID_ENABLED=1 - # Switch to the root user so we can do root things USER root -COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/ - # Set the working directory WORKDIR /var/www/html @@ -53,5 +48,6 @@ FROM base AS production # Copy the assets from the assets image COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules + # Drop back to the www-data user USER www-data diff --git a/README.md b/README.md index acb0b5c..73b9a6b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ [![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml) TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel. -It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, it’s the most popular community-driven BYOS. +It enables you to manage TRMNL devices, generate screens dynamically, and can act as a proxy for the native cloud service (native plugins, recipes). + +If you are looking for a Laravel package designed to streamline the development of both public and private TRMNL plugins, check out [bnussbau/trmnl-laravel](https://github.com/bnussbau/laravel-trmnl). ![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot-dark.png) @@ -14,32 +16,21 @@ It allows you to manage TRMNL devices, generate screens using **native plugins** * 📡 Device Information – Display battery status, WiFi strength, firmware version, and more. * 🔍 Auto-Join – Automatically detects and adds devices from your local network. -* 🖥️ Screen Generation – Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code. - * Support for TRMNL [Design Framework](https://usetrmnl.com/framework) - * Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/) - * Import from the [TRMNL community recipe catalog](https://usetrmnl.com/recipes) - * Supported Devices - * TRMNL OG (1-bit & 2-bit) - * SeeedStudio TRMNL 7,5" (OG) DIY Kit - * Seeed Studio (XIAO 7.5" ePaper Panel) - * reTerminal E1001 Monochrome ePaper Display - * Custom ESP32 with TRMNL firmware - * E-Reader Devices - * KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader)) - * Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27)) - * Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook)) - * Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo)) - * Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android) - * Raspberry Pi (HDMI output) [trmnl-display](https://github.com/usetrmnl/trmnl-display) +* 🖥️ Screen Generation – Supports Plugins (even Mashups), Recipes, API, Markup, or updates via Code. + * Supported Devices / Apps: TRMNL, ESP32 with TRMNL firmware, [trmnl-android](https://github.com/usetrmnl/trmnl-android), [trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27), … * 🔄 TRMNL API Proxy – Can act as a proxy for the native cloud service (requires TRMNL Developer Edition). * This enables a hybrid setup – for example, you can update your custom Train Monitor every 5 minutes in the morning, while displaying native TRMNL plugins throughout the day. * 🌙 Dark Mode – Switch between light and dark mode. * 🐳 Deployment – Dockerized setup for easier hosting (Dockerfile, docker-compose). -* 💾 Flexible Database configuration – uses SQLite by default, also compatible with MySQL or PostgreSQL * 🛠️ Devcontainer support for easier development. ![Devices](README_byos-devices.jpeg) +### 🎯 Target Audience + +This project is for developers who are looking for a self-hosted server for devices running the TRMNL firmware. +It serves as a starter kit, giving you the flexibility to build and extend it however you like. + ### Support ❤️ This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau). @@ -49,8 +40,6 @@ or [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/bnussbau) -[GitHub Sponsors](https://github.com/sponsors/bnussbau/) - ### Hosting Run everywhere, where Docker is supported: Raspberry Pi, VPS, NAS, Container Cloud Service (Cloud Run, ...). @@ -76,12 +65,9 @@ docker compose up -d If you’re using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy BYOS Laravel using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel). It’s a quick way to get started without having to manually manage Docker setup. -#### PikaPods +### PikaPods You can vote for TRMNL BYOS Laravel to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel) -#### Umbrel -Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store). - #### Other Hosting Options Laravel Forge, or bare metal PHP server with Nginx or Apache is also supported. @@ -218,12 +204,6 @@ You can dynamically update screens by sending a POST request. } ``` -### Releated Work -* [bnussbau/laravel-trmnl-blade](https://github.com/bnussbau/laravel-trmnl-blade) – Blade Components on top of the TRMNL Design System -* [bnussbau/trmnl-pipeline-php](https://github.com/bnussbau/trmnl-pipeline-php) – Browser Rendering and Image Conversion Pipeline with support for TRMNL Models API -* [bnussbau/trmnl-recipe-catalog](https://github.com/bnussbau/trmnl-recipe-catalog) – A community-driven catalog of public repositories containing trmnlp-compatible recipes. - - ### 🤝 Contribution Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details. diff --git a/app/Console/Commands/FirmwareCheckCommand.php b/app/Console/Commands/FirmwareCheckCommand.php index 91922ba..f407314 100644 --- a/app/Console/Commands/FirmwareCheckCommand.php +++ b/app/Console/Commands/FirmwareCheckCommand.php @@ -23,7 +23,7 @@ class FirmwareCheckCommand extends Command ); $latestFirmware = Firmware::getLatest(); - if ($latestFirmware instanceof Firmware) { + if ($latestFirmware) { table( rows: [ ['Latest Version', $latestFirmware->version_tag], diff --git a/app/Console/Commands/FirmwareUpdateCommand.php b/app/Console/Commands/FirmwareUpdateCommand.php index bd43786..97d9d58 100644 --- a/app/Console/Commands/FirmwareUpdateCommand.php +++ b/app/Console/Commands/FirmwareUpdateCommand.php @@ -42,14 +42,15 @@ class FirmwareUpdateCommand extends Command label: 'Which devices should be updated?', options: [ 'all' => 'ALL Devices', - ...Device::all()->mapWithKeys(fn ($device): array => + ...Device::all()->mapWithKeys(function ($device) { // without _ returns index - ["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"])->toArray(), + return ["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"]; + })->toArray(), ], scroll: 10 ); - if ($devices === []) { + if (empty($devices)) { $this->error('No devices selected. Aborting.'); return; @@ -58,7 +59,9 @@ class FirmwareUpdateCommand extends Command if (in_array('all', $devices)) { $devices = Device::pluck('id')->toArray(); } else { - $devices = array_map(fn ($selected): int => (int) str_replace('_', '', $selected), $devices); + $devices = array_map(function ($selected) { + return (int) str_replace('_', '', $selected); + }, $devices); } foreach ($devices as $deviceId) { diff --git a/app/Console/Commands/GenerateDefaultImagesCommand.php b/app/Console/Commands/GenerateDefaultImagesCommand.php deleted file mode 100644 index e2887df..0000000 --- a/app/Console/Commands/GenerateDefaultImagesCommand.php +++ /dev/null @@ -1,201 +0,0 @@ -info('Starting generation of default images for all device models...'); - - $deviceModels = DeviceModel::all(); - - if ($deviceModels->isEmpty()) { - $this->warn('No device models found in the database.'); - - return self::SUCCESS; - } - - $this->info("Found {$deviceModels->count()} device models to process."); - - // Create the target directory - $targetDir = 'images/default-screens'; - if (! Storage::disk('public')->exists($targetDir)) { - Storage::disk('public')->makeDirectory($targetDir); - $this->info("Created directory: {$targetDir}"); - } - - $successCount = 0; - $skipCount = 0; - $errorCount = 0; - - foreach ($deviceModels as $deviceModel) { - $this->info("Processing device model: {$deviceModel->label} (ID: {$deviceModel->id})"); - - try { - // Process setup-logo - $setupResult = $this->transformImage('setup-logo', $deviceModel, $targetDir); - if ($setupResult) { - ++$successCount; - } else { - ++$skipCount; - } - - // Process sleep - $sleepResult = $this->transformImage('sleep', $deviceModel, $targetDir); - if ($sleepResult) { - ++$successCount; - } else { - ++$skipCount; - } - - } catch (Exception $e) { - $this->error("Error processing device model {$deviceModel->label}: ".$e->getMessage()); - ++$errorCount; - } - } - - $this->info("\nGeneration completed!"); - $this->info("Successfully processed: {$successCount} images"); - $this->info("Skipped (already exist): {$skipCount} images"); - $this->info("Errors: {$errorCount} images"); - - return self::SUCCESS; - } - - /** - * Transform a single image for a device model using Blade templates - */ - private function transformImage(string $imageType, DeviceModel $deviceModel, string $targetDir): bool - { - // Generate filename: {width}_{height}_{bit_depth}_{rotation}.{extension} - $extension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; - $filename = "{$deviceModel->width}_{$deviceModel->height}_{$deviceModel->bit_depth}_{$deviceModel->rotation}.{$extension}"; - $targetPath = "{$targetDir}/{$imageType}_{$filename}"; - - // Check if target already exists and force is not set - if (Storage::disk('public')->exists($targetPath) && ! $this->option('force')) { - $this->line(" Skipping {$imageType} - already exists: {$filename}"); - - return false; - } - - try { - // Create custom Browsershot instance if using AWS Lambda - $browsershotInstance = null; - if (config('app.puppeteer_mode') === 'sidecar-aws') { - $browsershotInstance = new BrowsershotLambda(); - } - - // Generate HTML from Blade template - $html = $this->generateHtmlFromTemplate($imageType, $deviceModel); - // dump($html); - - $browserStage = new BrowserStage($browsershotInstance); - $browserStage->html($html); - - // Set timezone from app config (no user context in this command) - $browserStage->timezone(config('app.timezone')); - - $browserStage - ->width($deviceModel->width) - ->height($deviceModel->height); - - $browserStage->setBrowsershotOption('waitUntil', 'networkidle0'); - - if (config('app.puppeteer_docker')) { - $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']); - } - - $outputPath = Storage::disk('public')->path($targetPath); - - $imageStage = new ImageStage(); - $imageStage->format($extension) - ->width($deviceModel->width) - ->height($deviceModel->height) - ->colors($deviceModel->colors) - ->bitDepth($deviceModel->bit_depth) - ->rotation($deviceModel->rotation) - // ->offsetX($deviceModel->offset_x) - // ->offsetY($deviceModel->offset_y) - ->outputPath($outputPath); - - (new TrmnlPipeline())->pipe($browserStage) - ->pipe($imageStage) - ->process(); - - if (! file_exists($outputPath)) { - throw new RuntimeException('Image file was not created: '.$outputPath); - } - - if (filesize($outputPath) === 0) { - throw new RuntimeException('Image file is empty: '.$outputPath); - } - - $this->line(" ✓ Generated {$imageType}: {$filename}"); - - return true; - - } catch (Exception $e) { - $this->error(" ✗ Failed to generate {$imageType} for {$deviceModel->label}: ".$e->getMessage()); - - return false; - } - } - - /** - * Generate HTML from Blade template for the given image type and device model - */ - private function generateHtmlFromTemplate(string $imageType, DeviceModel $deviceModel): string - { - // Map image type to template name - $templateName = match ($imageType) { - 'setup-logo' => 'default-screens.setup', - 'sleep' => 'default-screens.sleep', - default => throw new InvalidArgumentException("Invalid image type: {$imageType}") - }; - - // Determine device properties from DeviceModel - $deviceVariant = $deviceModel->name ?? 'og'; - $colorDepth = $deviceModel->color_depth ?? '1bit'; // Use the accessor method - $scaleLevel = $deviceModel->scale_level; // Use the accessor method - $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode - - // Render the Blade template - return view($templateName, [ - 'noBleed' => false, - 'darkMode' => $darkMode, - 'deviceVariant' => $deviceVariant, - 'colorDepth' => $colorDepth, - 'scaleLevel' => $scaleLevel, - ])->render(); - } -} diff --git a/app/Console/Commands/MashupCreateCommand.php b/app/Console/Commands/MashupCreateCommand.php index 7201274..d6f1378 100644 --- a/app/Console/Commands/MashupCreateCommand.php +++ b/app/Console/Commands/MashupCreateCommand.php @@ -9,6 +9,9 @@ use App\Models\Plugin; use Illuminate\Console\Command; use Illuminate\Support\Collection; +use function Laravel\Prompts\select; +use function Laravel\Prompts\text; + class MashupCreateCommand extends Command { /** @@ -28,17 +31,17 @@ class MashupCreateCommand extends Command /** * Execute the console command. */ - public function handle(): int + public function handle() { // Select device $device = $this->selectDevice(); - if (! $device instanceof Device) { + if (! $device) { return 1; } // Select playlist $playlist = $this->selectPlaylist($device); - if (! $playlist instanceof Playlist) { + if (! $playlist) { return 1; } @@ -85,9 +88,9 @@ class MashupCreateCommand extends Command return null; } - $deviceId = $this->choice( - 'Select a device', - $devices->mapWithKeys(fn ($device): array => [$device->id => $device->name])->toArray() + $deviceId = select( + label: 'Select a device', + options: $devices->mapWithKeys(fn ($device) => [$device->id => $device->name])->toArray() ); return $devices->firstWhere('id', $deviceId); @@ -103,9 +106,9 @@ class MashupCreateCommand extends Command return null; } - $playlistId = $this->choice( - 'Select a playlist', - $playlists->mapWithKeys(fn (Playlist $playlist): array => [$playlist->id => $playlist->name])->toArray() + $playlistId = select( + label: 'Select a playlist', + options: $playlists->mapWithKeys(fn (Playlist $playlist) => [$playlist->id => $playlist->name])->toArray() ); return $playlists->firstWhere('id', $playlistId); @@ -113,29 +116,24 @@ class MashupCreateCommand extends Command protected function selectLayout(): ?string { - return $this->choice( - 'Select a layout', - PlaylistItem::getAvailableLayouts() + return select( + label: 'Select a layout', + options: PlaylistItem::getAvailableLayouts() ); } protected function getMashupName(): ?string { - $name = $this->ask('Enter a name for this mashup', 'Mashup'); - - if (mb_strlen((string) $name) < 2) { - $this->error('The name must be at least 2 characters.'); - - return null; - } - - if (mb_strlen((string) $name) > 50) { - $this->error('The name must not exceed 50 characters.'); - - return null; - } - - return $name; + return text( + label: 'Enter a name for this mashup', + required: true, + default: 'Mashup', + validate: fn (string $value) => match (true) { + mb_strlen($value) < 1 => 'The name must be at least 2 characters.', + mb_strlen($value) > 50 => 'The name must not exceed 50 characters.', + default => null, + } + ); } protected function selectPlugins(string $layout): Collection @@ -150,7 +148,7 @@ class MashupCreateCommand extends Command } $selectedPlugins = collect(); - $availablePlugins = $plugins->mapWithKeys(fn ($plugin): array => [$plugin->id => $plugin->name])->toArray(); + $availablePlugins = $plugins->mapWithKeys(fn ($plugin) => [$plugin->id => $plugin->name])->toArray(); for ($i = 0; $i < $requiredCount; ++$i) { $position = match ($i) { @@ -161,9 +159,9 @@ class MashupCreateCommand extends Command default => ($i + 1).'th' }; - $pluginId = $this->choice( - "Select the $position plugin", - $availablePlugins + $pluginId = select( + label: "Select the $position plugin", + options: $availablePlugins ); $selectedPlugins->push($plugins->firstWhere('id', $pluginId)); diff --git a/app/Console/Commands/OidcTestCommand.php b/app/Console/Commands/OidcTestCommand.php index 81dff0b..c04f263 100644 --- a/app/Console/Commands/OidcTestCommand.php +++ b/app/Console/Commands/OidcTestCommand.php @@ -26,7 +26,7 @@ class OidcTestCommand extends Command /** * Execute the console command. */ - public function handle(): int + public function handle() { $this->info('Testing OIDC Configuration...'); $this->newLine(); @@ -40,18 +40,13 @@ class OidcTestCommand extends Command $clientId = config('services.oidc.client_id'); $clientSecret = config('services.oidc.client_secret'); $redirect = config('services.oidc.redirect'); - if (! $redirect) { - $redirect = config('app.url', 'http://localhost').'/auth/oidc/callback'; - } $scopes = config('services.oidc.scopes', []); - $defaultScopes = ['openid', 'profile', 'email']; - $effectiveScopes = empty($scopes) ? $defaultScopes : $scopes; $this->line('OIDC Endpoint: '.($endpoint ? "✅ {$endpoint}" : '❌ Not set')); $this->line('Client ID: '.($clientId ? "✅ {$clientId}" : '❌ Not set')); $this->line('Client Secret: '.($clientSecret ? '✅ Set' : '❌ Not set')); $this->line('Redirect URL: '.($redirect ? "✅ {$redirect}" : '❌ Not set')); - $this->line('Scopes: ✅ '.implode(', ', $effectiveScopes)); + $this->line('Scopes: '.(empty($scopes) ? '❌ Not set' : '✅ '.implode(', ', $scopes))); $this->newLine(); diff --git a/app/Console/Commands/ScreenGeneratorCommand.php b/app/Console/Commands/ScreenGeneratorCommand.php index c0a2cc3..ac74fba 100644 --- a/app/Console/Commands/ScreenGeneratorCommand.php +++ b/app/Console/Commands/ScreenGeneratorCommand.php @@ -25,7 +25,7 @@ class ScreenGeneratorCommand extends Command /** * Execute the console command. */ - public function handle(): int + public function handle() { $deviceId = $this->argument('deviceId'); $view = $this->argument('view'); diff --git a/app/Jobs/CleanupDeviceLogsJob.php b/app/Jobs/CleanupDeviceLogsJob.php index d2f1dd9..b49f507 100644 --- a/app/Jobs/CleanupDeviceLogsJob.php +++ b/app/Jobs/CleanupDeviceLogsJob.php @@ -18,7 +18,7 @@ class CleanupDeviceLogsJob implements ShouldQueue */ public function handle(): void { - Device::each(function ($device): void { + Device::each(function ($device) { $keepIds = $device->logs()->latest('device_timestamp')->take(50)->pluck('id'); // Delete all other logs for this device diff --git a/app/Jobs/FetchDeviceModelsJob.php b/app/Jobs/FetchDeviceModelsJob.php index 475c5c7..695041f 100644 --- a/app/Jobs/FetchDeviceModelsJob.php +++ b/app/Jobs/FetchDeviceModelsJob.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Jobs; use App\Models\DeviceModel; -use App\Models\DevicePalette; use Exception; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -21,8 +20,6 @@ final class FetchDeviceModelsJob implements ShouldQueue private const API_URL = 'https://usetrmnl.com/api/models'; - private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes'; - /** * Create a new job instance. */ @@ -37,8 +34,6 @@ final class FetchDeviceModelsJob implements ShouldQueue public function handle(): void { try { - $this->processPalettes(); - $response = Http::timeout(30)->get(self::API_URL); if (! $response->successful()) { @@ -74,86 +69,6 @@ final class FetchDeviceModelsJob implements ShouldQueue } } - /** - * Process palettes from API and update/create records. - */ - private function processPalettes(): void - { - try { - $response = Http::timeout(30)->get(self::PALETTES_API_URL); - - if (! $response->successful()) { - Log::error('Failed to fetch palettes from API', [ - 'status' => $response->status(), - 'body' => $response->body(), - ]); - - return; - } - - $data = $response->json('data', []); - - if (! is_array($data)) { - Log::error('Invalid response format from palettes API', [ - 'response' => $response->json(), - ]); - - return; - } - - foreach ($data as $paletteData) { - try { - $this->updateOrCreatePalette($paletteData); - } catch (Exception $e) { - Log::error('Failed to process palette', [ - 'palette_data' => $paletteData, - 'error' => $e->getMessage(), - ]); - } - } - - Log::info('Successfully fetched and updated palettes', [ - 'count' => count($data), - ]); - - } catch (Exception $e) { - Log::error('Exception occurred while fetching palettes', [ - 'message' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - } - } - - /** - * Update or create a palette record. - */ - private function updateOrCreatePalette(array $paletteData): void - { - $name = $paletteData['id'] ?? null; - - if (! $name) { - Log::warning('Palette data missing id field', [ - 'palette_data' => $paletteData, - ]); - - return; - } - - $attributes = [ - 'name' => $name, - 'description' => $paletteData['name'] ?? '', - 'grays' => $paletteData['grays'] ?? 2, - 'colors' => $paletteData['colors'] ?? null, - 'framework_class' => $paletteData['framework_class'] ?? '', - 'source' => 'api', - ]; - - DevicePalette::updateOrCreate( - ['name' => $name], - $attributes - ); - } - /** * Process the device models data and update/create records. */ @@ -199,49 +114,12 @@ final class FetchDeviceModelsJob implements ShouldQueue 'offset_x' => $modelData['offset_x'] ?? 0, 'offset_y' => $modelData['offset_y'] ?? 0, 'published_at' => $modelData['published_at'] ?? null, - 'kind' => $modelData['kind'] ?? null, 'source' => 'api', ]; - // Set palette_id to the first palette from the model's palettes array - $firstPaletteId = $this->getFirstPaletteId($modelData); - if ($firstPaletteId) { - $attributes['palette_id'] = $firstPaletteId; - } - DeviceModel::updateOrCreate( ['name' => $name], $attributes ); } - - /** - * Get the first palette ID from model data. - */ - private function getFirstPaletteId(array $modelData): ?int - { - $paletteName = null; - - // Check for palette_ids array - if (isset($modelData['palette_ids']) && is_array($modelData['palette_ids']) && $modelData['palette_ids'] !== []) { - $paletteName = $modelData['palette_ids'][0]; - } - - // Check for palettes array (array of objects with id) - if (! $paletteName && isset($modelData['palettes']) && is_array($modelData['palettes']) && $modelData['palettes'] !== []) { - $firstPalette = $modelData['palettes'][0]; - if (is_array($firstPalette) && isset($firstPalette['id'])) { - $paletteName = $firstPalette['id']; - } - } - - if (! $paletteName) { - return null; - } - - // Look up palette by name to get the integer ID - $palette = DevicePalette::where('name', $paletteName)->first(); - - return $palette?->id; - } } diff --git a/app/Jobs/FetchProxyCloudResponses.php b/app/Jobs/FetchProxyCloudResponses.php index ac23130..b560085 100644 --- a/app/Jobs/FetchProxyCloudResponses.php +++ b/app/Jobs/FetchProxyCloudResponses.php @@ -23,7 +23,7 @@ class FetchProxyCloudResponses implements ShouldQueue */ public function handle(): void { - Device::where('proxy_cloud', true)->each(function ($device): void { + Device::where('proxy_cloud', true)->each(function ($device) { if (! $device->getNextPlaylistItem()) { try { $response = Http::withHeaders([ diff --git a/app/Jobs/FirmwareDownloadJob.php b/app/Jobs/FirmwareDownloadJob.php index dfc851d..6b4fc36 100644 --- a/app/Jobs/FirmwareDownloadJob.php +++ b/app/Jobs/FirmwareDownloadJob.php @@ -18,7 +18,12 @@ class FirmwareDownloadJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public function __construct(private Firmware $firmware) {} + private Firmware $firmware; + + public function __construct(Firmware $firmware) + { + $this->firmware = $firmware; + } public function handle(): void { @@ -28,25 +33,16 @@ class FirmwareDownloadJob implements ShouldQueue try { $filename = "FW{$this->firmware->version_tag}.bin"; - $response = Http::get($this->firmware->url); + Http::sink(storage_path("app/public/firmwares/$filename")) + ->get($this->firmware->url); - if (! $response->successful()) { - throw new Exception('HTTP request failed with status: '.$response->status()); - } - - // Save the response content to file - Storage::disk('public')->put("firmwares/$filename", $response->body()); - - // Only update storage location if download was successful $this->firmware->update([ 'storage_location' => "firmwares/$filename", ]); } catch (ConnectionException $e) { Log::error('Firmware download failed: '.$e->getMessage()); - // Don't update storage_location on failure } catch (Exception $e) { Log::error('An unexpected error occurred: '.$e->getMessage()); - // Don't update storage_location on failure } } } diff --git a/app/Jobs/FirmwarePollJob.php b/app/Jobs/FirmwarePollJob.php index c1a2267..7110b9c 100644 --- a/app/Jobs/FirmwarePollJob.php +++ b/app/Jobs/FirmwarePollJob.php @@ -17,7 +17,12 @@ class FirmwarePollJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public function __construct(private bool $download = false) {} + private bool $download; + + public function __construct(bool $download = false) + { + $this->download = $download; + } public function handle(): void { diff --git a/app/Jobs/NotifyDeviceBatteryLowJob.php b/app/Jobs/NotifyDeviceBatteryLowJob.php index 9b1001b..2508365 100644 --- a/app/Jobs/NotifyDeviceBatteryLowJob.php +++ b/app/Jobs/NotifyDeviceBatteryLowJob.php @@ -15,6 +15,8 @@ class NotifyDeviceBatteryLowJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public function __construct() {} + public function handle(): void { $devices = Device::all(); @@ -30,11 +32,9 @@ class NotifyDeviceBatteryLowJob implements ShouldQueue continue; } + // Skip if battery is not low or notification was already sent - if ($batteryPercent > $batteryThreshold) { - continue; - } - if ($device->battery_notification_sent) { + if ($batteryPercent > $batteryThreshold || $device->battery_notification_sent) { continue; } diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php index 2387ac5..2bbb5a9 100644 --- a/app/Liquid/Filters/Data.php +++ b/app/Liquid/Filters/Data.php @@ -2,7 +2,6 @@ namespace App\Liquid\Filters; -use App\Liquid\Utils\ExpressionUtils; use Keepsuit\Liquid\Filters\FiltersProvider; /** @@ -73,7 +72,7 @@ class Data extends FiltersProvider */ public function sample(array $array): mixed { - if ($array === []) { + if (empty($array)) { return null; } @@ -90,47 +89,4 @@ class Data extends FiltersProvider { return json_decode($json, true); } - - /** - * Filter a collection using an expression - * - * @param mixed $input The collection to filter - * @param string $variable The variable name to use in the expression - * @param string $expression The expression to evaluate - * @return array The filtered collection - */ - public function where_exp(mixed $input, string $variable, string $expression): array - { - // Return input as-is if it's not an array or doesn't have values method - if (! is_array($input)) { - return is_string($input) ? [$input] : []; - } - - // Convert hash to array of values if needed - if (ExpressionUtils::isAssociativeArray($input)) { - $input = array_values($input); - } - - $condition = ExpressionUtils::parseCondition($expression); - $result = []; - - foreach ($input as $object) { - if (ExpressionUtils::evaluateCondition($condition, $variable, $object)) { - $result[] = $object; - } - } - - return $result; - } - - /** - * Convert array of strings to integers - * - * @param array $input Array of string numbers - * @return array Array of integers - */ - public function map_to_i(array $input): array - { - return array_map(intval(...), $input); - } } diff --git a/app/Liquid/Filters/Date.php b/app/Liquid/Filters/Date.php index 6bc81fc..2f730ac 100644 --- a/app/Liquid/Filters/Date.php +++ b/app/Liquid/Filters/Date.php @@ -2,7 +2,6 @@ namespace App\Liquid\Filters; -use App\Liquid\Utils\ExpressionUtils; use Carbon\Carbon; use Keepsuit\Liquid\Filters\FiltersProvider; @@ -23,33 +22,4 @@ class Date extends FiltersProvider return Carbon::now()->subDays($days)->toDateString(); } - - /** - * Format a date string with ordinal day (1st, 2nd, 3rd, etc.) - * - * @param string $dateStr The date string to parse - * @param string $strftimeExp The strftime format string with <> placeholder - * @return string The formatted date with ordinal day - */ - public function ordinalize(string $dateStr, string $strftimeExp): string - { - $date = Carbon::parse($dateStr); - $ordinalDay = $date->ordinal('day'); - - // Convert strftime format to PHP date format - $phpFormat = ExpressionUtils::strftimeToPhpFormat($strftimeExp); - - // Split the format string by the ordinal day placeholder - $parts = explode('<>', $phpFormat); - - if (count($parts) === 2) { - $before = $date->format($parts[0]); - $after = $date->format($parts[1]); - - return $before.$ordinalDay.$after; - } - - // Fallback: if no placeholder found, just format normally - return $date->format($phpFormat); - } } diff --git a/app/Liquid/Filters/Numbers.php b/app/Liquid/Filters/Numbers.php index 0e31de1..53d1973 100644 --- a/app/Liquid/Filters/Numbers.php +++ b/app/Liquid/Filters/Numbers.php @@ -40,11 +40,15 @@ class Numbers extends FiltersProvider $currency = 'GBP'; } - $locale = $delimiter === '.' && $separator === ',' ? 'de' : 'en'; + if ($delimiter === '.' && $separator === ',') { + $locale = 'de'; + } else { + $locale = 'en'; + } // 2 decimal places for floats, 0 for integers $decimal = is_float($value + 0) ? 2 : 0; - return Number::currency($value, in: $currency, locale: $locale, precision: $decimal); + return Number::currency($value, in: $currency, precision: $decimal, locale: $locale); } } diff --git a/app/Liquid/Filters/StandardFilters.php b/app/Liquid/Filters/StandardFilters.php deleted file mode 100644 index 4db86a0..0000000 --- a/app/Liquid/Filters/StandardFilters.php +++ /dev/null @@ -1,20 +0,0 @@ - 'and', - 'left' => self::parseCondition(mb_trim($parts[0])), - 'right' => self::parseCondition(mb_trim($parts[1])), - ]; - } - - if (str_contains($expression, ' or ')) { - $parts = explode(' or ', $expression, 2); - - return [ - 'type' => 'or', - 'left' => self::parseCondition(mb_trim($parts[0])), - 'right' => self::parseCondition(mb_trim($parts[1])), - ]; - } - - // Handle comparison operators - $operators = ['>=', '<=', '!=', '==', '>', '<', '=']; - - foreach ($operators as $operator) { - if (str_contains($expression, $operator)) { - $parts = explode($operator, $expression, 2); - - return [ - 'type' => 'comparison', - 'left' => mb_trim($parts[0]), - 'operator' => $operator === '=' ? '==' : $operator, - 'right' => mb_trim($parts[1]), - ]; - } - } - - // If no operator found, treat as a simple expression - return [ - 'type' => 'simple', - 'expression' => $expression, - ]; - } - - /** - * Evaluate a condition against an object - */ - public static function evaluateCondition(array $condition, string $variable, mixed $object): bool - { - switch ($condition['type']) { - case 'and': - return self::evaluateCondition($condition['left'], $variable, $object) && - self::evaluateCondition($condition['right'], $variable, $object); - - case 'or': - if (self::evaluateCondition($condition['left'], $variable, $object)) { - return true; - } - - return self::evaluateCondition($condition['right'], $variable, $object); - - case 'comparison': - $leftValue = self::resolveValue($condition['left'], $variable, $object); - $rightValue = self::resolveValue($condition['right'], $variable, $object); - - return match ($condition['operator']) { - '==' => $leftValue === $rightValue, - '!=' => $leftValue !== $rightValue, - '>' => $leftValue > $rightValue, - '<' => $leftValue < $rightValue, - '>=' => $leftValue >= $rightValue, - '<=' => $leftValue <= $rightValue, - default => false, - }; - - case 'simple': - $value = self::resolveValue($condition['expression'], $variable, $object); - - return (bool) $value; - - default: - return false; - } - } - - /** - * Resolve a value from an expression, variable, or literal - */ - public static function resolveValue(string $expression, string $variable, mixed $object): mixed - { - $expression = mb_trim($expression); - - // If it's the variable name, return the object - if ($expression === $variable) { - return $object; - } - - // If it's a property access (e.g., "n.age"), resolve it - if (str_starts_with($expression, $variable.'.')) { - $property = mb_substr($expression, mb_strlen($variable) + 1); - if (is_array($object) && array_key_exists($property, $object)) { - return $object[$property]; - } - if (is_object($object) && property_exists($object, $property)) { - return $object->$property; - } - - return null; - } - - // Try to parse as a number - if (is_numeric($expression)) { - return str_contains($expression, '.') ? (float) $expression : (int) $expression; - } - - // Try to parse as boolean - if (in_array(mb_strtolower($expression), ['true', 'false'])) { - return mb_strtolower($expression) === 'true'; - } - - // Try to parse as null - if (mb_strtolower($expression) === 'null') { - return null; - } - - // Return as string (remove quotes if present) - if ((str_starts_with($expression, '"') && str_ends_with($expression, '"')) || - (str_starts_with($expression, "'") && str_ends_with($expression, "'"))) { - return mb_substr($expression, 1, -1); - } - - return $expression; - } - - /** - * Convert strftime format string to PHP date format string - * - * @param string $strftimeFormat The strftime format string - * @return string The PHP date format string - */ - public static function strftimeToPhpFormat(string $strftimeFormat): string - { - $conversions = [ - // Special Ruby format cases - '%N' => 'u', // Microseconds (Ruby) -> microseconds (PHP) - '%u' => 'u', // Microseconds (Ruby) -> microseconds (PHP) - '%-m' => 'n', // Month without leading zero (Ruby) -> month without leading zero (PHP) - '%-d' => 'j', // Day without leading zero (Ruby) -> day without leading zero (PHP) - '%-H' => 'G', // Hour without leading zero (Ruby) -> hour without leading zero (PHP) - '%-I' => 'g', // Hour 12h without leading zero (Ruby) -> hour 12h without leading zero (PHP) - '%-M' => 'i', // Minute without leading zero (Ruby) -> minute without leading zero (PHP) - '%-S' => 's', // Second without leading zero (Ruby) -> second without leading zero (PHP) - '%z' => 'O', // Timezone offset (Ruby) -> timezone offset (PHP) - '%Z' => 'T', // Timezone name (Ruby) -> timezone name (PHP) - - // Standard strftime conversions - '%A' => 'l', // Full weekday name - '%a' => 'D', // Abbreviated weekday name - '%B' => 'F', // Full month name - '%b' => 'M', // Abbreviated month name - '%Y' => 'Y', // Full year (4 digits) - '%y' => 'y', // Year without century (2 digits) - '%m' => 'm', // Month as decimal number (01-12) - '%d' => 'd', // Day of month as decimal number (01-31) - '%H' => 'H', // Hour in 24-hour format (00-23) - '%I' => 'h', // Hour in 12-hour format (01-12) - '%M' => 'i', // Minute as decimal number (00-59) - '%S' => 's', // Second as decimal number (00-59) - '%p' => 'A', // AM/PM - '%P' => 'a', // am/pm - '%j' => 'z', // Day of year as decimal number (001-366) - '%w' => 'w', // Weekday as decimal number (0-6, Sunday is 0) - '%U' => 'W', // Week number of year (00-53, Sunday is first day) - '%W' => 'W', // Week number of year (00-53, Monday is first day) - '%c' => 'D M j H:i:s Y', // Date and time representation - '%x' => 'm/d/Y', // Date representation - '%X' => 'H:i:s', // Time representation - ]; - - return str_replace(array_keys($conversions), array_values($conversions), $strftimeFormat); - } -} diff --git a/app/Livewire/Actions/DeviceAutoJoin.php b/app/Livewire/Actions/DeviceAutoJoin.php index 183add4..c16322c 100644 --- a/app/Livewire/Actions/DeviceAutoJoin.php +++ b/app/Livewire/Actions/DeviceAutoJoin.php @@ -10,14 +10,14 @@ class DeviceAutoJoin extends Component public bool $isFirstUser = false; - public function mount(): void + public function mount() { $this->deviceAutojoin = auth()->user()->assign_new_devices; $this->isFirstUser = auth()->user()->id === 1; } - public function updating($name, $value): void + public function updating($name, $value) { $this->validate([ 'deviceAutojoin' => 'boolean', @@ -30,7 +30,7 @@ class DeviceAutoJoin extends Component } } - public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory + public function render() { return view('livewire.actions.device-auto-join'); } diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php index c26fa72..45993bb 100644 --- a/app/Livewire/Actions/Logout.php +++ b/app/Livewire/Actions/Logout.php @@ -10,7 +10,7 @@ class Logout /** * Log the current user out of the application. */ - public function __invoke(): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse + public function __invoke() { Auth::guard('web')->logout(); diff --git a/app/Livewire/DeviceDashboard.php b/app/Livewire/DeviceDashboard.php index a2a3692..78309cb 100644 --- a/app/Livewire/DeviceDashboard.php +++ b/app/Livewire/DeviceDashboard.php @@ -6,7 +6,7 @@ use Livewire\Component; class DeviceDashboard extends Component { - public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory + public function render() { return view('livewire.device-dashboard', ['devices' => auth()->user()->devices()->paginate(10)]); } diff --git a/app/Models/Device.php b/app/Models/Device.php index 3583f48..5001a22 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -10,24 +10,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Storage; -/** - * @property-read DeviceModel|null $deviceModel - * @property-read DevicePalette|null $palette - */ class Device extends Model { use HasFactory; protected $guarded = ['id']; - /** - * Set the MAC address attribute, normalizing to uppercase. - */ - public function setMacAddressAttribute(?string $value): void - { - $this->attributes['mac_address'] = $value ? mb_strtoupper($value) : null; - } - protected $casts = [ 'battery_notification_sent' => 'boolean', 'proxy_cloud' => 'boolean', @@ -44,7 +32,7 @@ class Device extends Model 'pause_until' => 'datetime', ]; - public function getBatteryPercentAttribute(): int|float + public function getBatteryPercentAttribute() { $volts = $this->last_battery_voltage; @@ -92,7 +80,7 @@ class Device extends Model return round($voltage, 2); } - public function getWifiStrengthAttribute(): int + public function getWifiStrengthAttribute() { $rssi = $this->last_rssi_level; if ($rssi >= 0) { @@ -115,7 +103,11 @@ class Device extends Model return true; } - return $this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware']; + if ($this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware']) { + return true; + } + + return false; } public function getFirmwareUrlAttribute(): ?string @@ -196,35 +188,6 @@ class Device extends Model return $this->belongsTo(DeviceModel::class); } - public function palette(): BelongsTo - { - return $this->belongsTo(DevicePalette::class, 'palette_id'); - } - - /** - * Get the color depth string (e.g., "4bit") for the associated device model. - */ - public function colorDepth(): ?string - { - return $this->deviceModel?->color_depth; - } - - /** - * Get the scale level (e.g., large/xlarge/xxlarge) for the associated device model. - */ - public function scaleLevel(): ?string - { - return $this->deviceModel?->scale_level; - } - - /** - * Get the device variant name, defaulting to 'og' if not available. - */ - public function deviceVariant(): string - { - return $this->deviceModel->name ?? 'og'; - } - public function logs(): HasMany { return $this->hasMany(DeviceLog::class); @@ -241,7 +204,7 @@ class Device extends Model return false; } - $now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now(); + $now = $now ? Carbon::instance($now) : now(); // Handle overnight ranges (e.g. 22:00 to 06:00) return $this->sleep_mode_from < $this->sleep_mode_to @@ -255,7 +218,7 @@ class Device extends Model return null; } - $now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now(); + $now = $now ? Carbon::instance($now) : now(); $from = $this->sleep_mode_from; $to = $this->sleep_mode_to; diff --git a/app/Models/DeviceModel.php b/app/Models/DeviceModel.php index 6132a76..c9de2af 100644 --- a/app/Models/DeviceModel.php +++ b/app/Models/DeviceModel.php @@ -6,11 +6,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -/** - * @property-read DevicePalette|null $palette - */ final class DeviceModel extends Model { use HasFactory; @@ -28,51 +24,4 @@ final class DeviceModel extends Model 'offset_y' => 'integer', 'published_at' => 'datetime', ]; - - public function getColorDepthAttribute(): ?string - { - if (! $this->bit_depth) { - return null; - } - - if ($this->bit_depth === 3) { - return '2bit'; - } - - // if higher than 4 return 4bit - if ($this->bit_depth > 4) { - return '4bit'; - } - - return $this->bit_depth.'bit'; - } - - /** - * Returns the scale level based on the device width. - */ - public function getScaleLevelAttribute(): ?string - { - if (! $this->width) { - return null; - } - - if ($this->width > 800 && $this->width <= 1000) { - return 'large'; - } - - if ($this->width > 1000 && $this->width <= 1400) { - return 'xlarge'; - } - - if ($this->width > 1400) { - return 'xxlarge'; - } - - return null; - } - - public function palette(): BelongsTo - { - return $this->belongsTo(DevicePalette::class, 'palette_id'); - } } diff --git a/app/Models/DevicePalette.php b/app/Models/DevicePalette.php deleted file mode 100644 index 54b0876..0000000 --- a/app/Models/DevicePalette.php +++ /dev/null @@ -1,23 +0,0 @@ - 'integer', - 'colors' => 'array', - ]; -} diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index b4daf5e..d20798c 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -37,33 +37,26 @@ class Playlist extends Model return false; } - // Get user's timezone or fall back to app timezone - $timezone = $this->device->user->timezone ?? config('app.timezone'); - $now = now($timezone); - - // Check weekday (using timezone-aware time) - if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) { - return false; + // Check weekday + if ($this->weekdays !== null) { + if (! in_array(now()->dayOfWeek, $this->weekdays)) { + return false; + } } if ($this->active_from !== null && $this->active_until !== null) { - // Create timezone-aware datetime objects for active_from and active_until - $activeFrom = $now->copy() - ->setTimeFrom($this->active_from) - ->timezone($timezone); - - $activeUntil = $now->copy() - ->setTimeFrom($this->active_until) - ->timezone($timezone); + $now = now(); // Handle time ranges that span across midnight - if ($activeFrom > $activeUntil) { + if ($this->active_from > $this->active_until) { // Time range spans midnight (e.g., 09:01 to 03:58) - if ($now >= $activeFrom || $now <= $activeUntil) { + if ($now >= $this->active_from || $now <= $this->active_until) { + return true; + } + } else { + if ($now >= $this->active_from && $now <= $this->active_until) { return true; } - } elseif ($now >= $activeFrom && $now <= $activeUntil) { - return true; } return false; diff --git a/app/Models/PlaylistItem.php b/app/Models/PlaylistItem.php index ad11f1d..2459257 100644 --- a/app/Models/PlaylistItem.php +++ b/app/Models/PlaylistItem.php @@ -135,13 +135,10 @@ class PlaylistItem extends Model /** * Render all plugins with appropriate layout */ - public function render(?Device $device = null): string + public function render(): string { if (! $this->isMashup()) { return view('trmnl-layouts.single', [ - 'colorDepth' => $device?->colorDepth(), - 'deviceVariant' => $device?->deviceVariant() ?? 'og', - 'scaleLevel' => $device?->scaleLevel(), 'slot' => $this->plugin instanceof Plugin ? $this->plugin->render('full', false) : throw new Exception('Invalid plugin instance'), @@ -153,7 +150,9 @@ class PlaylistItem extends Model $plugins = Plugin::whereIn('id', $pluginIds)->get(); // Sort the collection to match plugin_ids order - $plugins = $plugins->sortBy(fn ($plugin): int|string|false => array_search($plugin->id, $pluginIds))->values(); + $plugins = $plugins->sortBy(function ($plugin) use ($pluginIds) { + return array_search($plugin->id, $pluginIds); + })->values(); foreach ($plugins as $index => $plugin) { $size = $this->getLayoutSize($index); @@ -161,9 +160,6 @@ class PlaylistItem extends Model } return view('trmnl-layouts.mashup', [ - 'colorDepth' => $device?->colorDepth(), - 'deviceVariant' => $device?->deviceVariant() ?? 'og', - 'scaleLevel' => $device?->scaleLevel(), 'mashupLayout' => $this->getMashupLayoutType(), 'slot' => implode('', $pluginMarkups), ])->render(); diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 68f8e7e..7290381 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -7,25 +7,17 @@ use App\Liquid\Filters\Data; use App\Liquid\Filters\Date; use App\Liquid\Filters\Localization; use App\Liquid\Filters\Numbers; -use App\Liquid\Filters\StandardFilters; use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\Uniqueness; use App\Liquid\Tags\TemplateTag; -use App\Services\Plugin\Parsers\ResponseParserRegistry; -use App\Services\PluginImportService; -use Carbon\Carbon; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Blade; 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; @@ -42,34 +34,17 @@ class Plugin extends Model 'markup_language' => 'string', 'configuration' => 'json', 'configuration_template' => 'json', - 'no_bleed' => 'boolean', - 'dark_mode' => 'boolean', - 'preferred_renderer' => 'string', - 'plugin_type' => 'string', - 'alias' => 'boolean', ]; protected static function boot() { parent::boot(); - static::creating(function ($model): void { + static::creating(function ($model) { if (empty($model->uuid)) { $model->uuid = Str::uuid(); } }); - - static::updating(function ($model): void { - // Reset image cache when markup changes - if ($model->isDirty('render_markup')) { - $model->current_image = null; - } - }); - - // Sanitize configuration template on save - static::saving(function ($model): void { - $model->sanitizeTemplate(); - }); } public function user() @@ -77,25 +52,6 @@ class Plugin extends Model return $this->belongsTo(User::class); } - // sanitize configuration template descriptions and help texts (since they allow HTML rendering) - protected function sanitizeTemplate(): void - { - $template = $this->configuration_template; - - if (isset($template['custom_fields']) && is_array($template['custom_fields'])) { - foreach ($template['custom_fields'] as &$field) { - if (isset($field['description'])) { - $field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']); - } - if (isset($field['help_text'])) { - $field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']); - } - } - - $this->configuration_template = $template; - } - } - public function hasMissingRequiredConfigurationFields(): bool { if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) { @@ -125,7 +81,7 @@ class Plugin extends Model $currentValue = $this->configuration[$fieldKey] ?? null; // If the field has a default value and no current value is set, it's not missing - if ((in_array($currentValue, [null, '', []], true)) && ! isset($field['default'])) { + 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 } } @@ -136,11 +92,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()); @@ -154,88 +105,108 @@ class Plugin extends Model public function updateDataPayload(): void { - if ($this->data_strategy !== 'polling' || ! $this->polling_url) { - return; - } - $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; + if ($this->data_strategy === 'polling' && $this->polling_url) { - // resolve headers - if ($this->polling_header) { - $resolvedHeader = $this->resolveLiquidVariables($this->polling_header); - $headerLines = explode("\n", mb_trim($resolvedHeader)); - foreach ($headerLines as $line) { - $parts = explode(':', $line, 2); - if (count($parts) === 2) { - $headers[mb_trim($parts[0])] = mb_trim($parts[1]); + $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; + + if ($this->polling_header) { + // Resolve Liquid variables in the polling header + $resolvedHeader = $this->resolveLiquidVariables($this->polling_header); + $headerLines = explode("\n", mb_trim($resolvedHeader)); + foreach ($headerLines as $line) { + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $headers[mb_trim($parts[0])] = mb_trim($parts[1]); + } } } - } - // resolve and clean URLs - $resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url); - $urls = array_values(array_filter( // array_values ensures 0, 1, 2... - array_map('trim', explode("\n", $resolvedPollingUrls)), - fn ($url): bool => filled($url) - )); + // Split URLs by newline and filter out empty lines + $urls = array_filter( + array_map('trim', explode("\n", $this->polling_url)), + fn ($url) => ! empty($url) + ); - $combinedResponse = []; + // If only one URL, use the original logic without nesting + if (count($urls) === 1) { + $url = reset($urls); + $httpRequest = Http::withHeaders($headers); - // Loop through all URLs (Handles 1 or many) - foreach ($urls as $index => $url) { - $httpRequest = Http::withHeaders($headers); - - if ($this->polling_verb === 'post' && $this->polling_body) { - $resolvedBody = $this->resolveLiquidVariables($this->polling_body); - $httpRequest = $httpRequest->withBody($resolvedBody); - } - - try { - $httpResponse = ($this->polling_verb === 'post') - ? $httpRequest->post($url) - : $httpRequest->get($url); - - $response = $this->parseResponse($httpResponse); - - // Nest if it's a sequential array - if (array_keys($response) === range(0, count($response) - 1)) { - $combinedResponse["IDX_{$index}"] = ['data' => $response]; - } else { - $combinedResponse["IDX_{$index}"] = $response; + 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); } - } catch (Exception $e) { - Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage()); - $combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data']; - } - } - // unwrap IDX_0 if only one URL - $finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse; + // Resolve Liquid variables in the polling URL + $resolvedUrl = $this->resolveLiquidVariables($url); - $this->update([ - 'data_payload' => $finalPayload, - 'data_payload_updated_at' => now(), - ]); - } + try { + // Make the request based on the verb + if ($this->polling_verb === 'post') { + $response = $httpRequest->post($resolvedUrl)->json(); + } else { + $response = $httpRequest->get($resolvedUrl)->json(); + } - private function parseResponse(Response $httpResponse): array - { - $parsers = app(ResponseParserRegistry::class)->getParsers(); - - foreach ($parsers as $parser) { - $parserName = class_basename($parser); - - try { - $result = $parser->parse($httpResponse); - - if ($result !== null) { - return $result; + $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(), + ]); } - } catch (Exception $e) { - Log::warning("Failed to parse {$parserName} response: ".$e->getMessage()); - } - } - return ['error' => 'Failed to parse response']; + return; + } + + // 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' => $combinedResponse, + 'data_payload_updated_at' => now(), + ]); + } } /** @@ -243,15 +214,15 @@ class Plugin extends Model */ private function applyLiquidReplacements(string $template): string { - - $replacements = []; + $replacements = [ + 'date: "%N"' => 'date: "u"', + 'date: "%u"' => 'date: "u"', + '%-m/%-d/%Y' => 'm/d/Y', + ]; // Apply basic replacements $template = str_replace(array_keys($replacements), array_values($replacements), $template); - // Convert Ruby/strftime date formats to PHP date formats - $template = $this->convertDateFormats($template); - // Convert {% render "template" with %} syntax to {% render "template", %} syntax $template = preg_replace( '/{%\s*render\s+([^}]+?)\s+with\s+/i', @@ -263,235 +234,103 @@ class Plugin extends Model // This handles: {% for item in collection | filter: "key", "value" %} // Converts to: {% assign temp_filtered = collection | filter: "key", "value" %}{% for item in temp_filtered %} $template = preg_replace_callback( - '/{%\s*for\s+(\w+)\s+in\s+([^|%}]+)\s*\|\s*([^%}]+)%}/', - function (array $matches): string { - $variableName = mb_trim($matches[1]); - $collection = mb_trim($matches[2]); - $filter = mb_trim($matches[3]); - $tempVarName = '_temp_'.uniqid(); - + '/{%\s*for\s+(\w+)\s+in\s+([^|]+)\s*\|\s*([^}]+)%}/', + function ($matches) { + $variableName = trim($matches[1]); + $collection = trim($matches[2]); + $filter = trim($matches[3]); + $tempVarName = '_temp_' . uniqid(); + return "{% assign {$tempVarName} = {$collection} | {$filter} %}{% for {$variableName} in {$tempVarName} %}"; }, - (string) $template - ); - - return $template; - } - - /** - * Convert Ruby/strftime date formats to PHP date formats in Liquid templates - */ - private function convertDateFormats(string $template): string - { - // Handle date filter formats: date: "format" or date: 'format' - $template = preg_replace_callback( - '/date:\s*(["\'])([^"\']+)\1/', - function (array $matches): string { - $quote = $matches[1]; - $format = $matches[2]; - $convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format); - - return 'date: '.$quote.$convertedFormat.$quote; - }, $template ); - // Handle l_date filter formats: l_date: "format" or l_date: 'format' - $template = preg_replace_callback( - '/l_date:\s*(["\'])([^"\']+)\1/', - function (array $matches): string { - $quote = $matches[1]; - $format = $matches[2]; - $convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format); - - return 'l_date: '.$quote.$convertedFormat.$quote; - }, - (string) $template - ); - return $template; } - /** - * Check if a template contains a Liquid for loop pattern - * - * @param string $template The template string to check - * @return bool True if the template contains a for loop pattern - */ - private function containsLiquidForLoop(string $template): bool - { - return preg_match('/{%-?\s*for\s+/i', $template) === 1; - } - /** * Resolve Liquid variables in a template string using the Liquid template engine * - * Uses the external trmnl-liquid renderer when: - * - preferred_renderer is 'trmnl-liquid' - * - External renderer is enabled in config - * - Template contains a Liquid for loop pattern - * - * Otherwise uses the internal PHP-based Liquid renderer. - * * @param string $template The template string containing Liquid variables * @return string The resolved template with variables replaced with their values * * @throws LiquidException - * @throws Exception */ public function resolveLiquidVariables(string $template): string { // Get configuration variables - make them available at root level $variables = $this->configuration ?? []; - // Check if external renderer should be used - $useExternalRenderer = $this->preferred_renderer === 'trmnl-liquid' - && config('services.trmnl.liquid_enabled') - && $this->containsLiquidForLoop($template); - - if ($useExternalRenderer) { - // Use external Ruby liquid renderer - return $this->renderWithExternalLiquidRenderer($template, $variables); - } - // Use the Liquid template engine to resolve variables $environment = App::make('liquid.environment'); - $environment->filterRegistry->register(StandardFilters::class); $liquidTemplate = $environment->parseString($template); $context = $environment->newRenderContext(data: $variables); return $liquidTemplate->render($context); } - /** - * Render template using external Ruby liquid renderer - * - * @param string $template The liquid template string - * @param array $context The render context data - * @return string The rendered HTML - * - * @throws Exception - */ - private function renderWithExternalLiquidRenderer(string $template, array $context): string - { - $liquidPath = config('services.trmnl.liquid_path'); - - if (empty($liquidPath)) { - throw new Exception('External liquid renderer path is not configured'); - } - - // HTML encode the template - $encodedTemplate = htmlspecialchars($template, ENT_QUOTES, 'UTF-8'); - - // Encode context as JSON - $jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - - if ($jsonContext === false) { - throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg()); - } - - // Validate argument sizes - app(PluginImportService::class)->validateExternalRendererArguments($encodedTemplate, $jsonContext, $liquidPath); - - // Execute the external renderer - $process = Process::run([ - $liquidPath, - '--template', - $encodedTemplate, - '--context', - $jsonContext, - ]); - - if (! $process->successful()) { - $errorOutput = $process->errorOutput() ?: $process->output(); - throw new Exception('External liquid renderer failed: '.$errorOutput); - } - - return $process->output(); - } - /** * Render the plugin's markup * * @throws LiquidException */ - public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string + public function render(string $size = 'full', bool $standalone = true): string { - if ($this->plugin_type !== 'recipe') { - throw new InvalidArgumentException('Render method is only applicable for recipe plugins.'); - } - if ($this->render_markup) { $renderedContent = ''; if ($this->markup_language === 'liquid') { - // Get timezone from user or fall back to app timezone - $timezone = $this->user->timezone ?? config('app.timezone'); + // Create a custom environment with inline templates support + $inlineFileSystem = new InlineTemplatesFileSystem(); + $environment = new \Keepsuit\Liquid\Environment( + fileSystem: $inlineFileSystem, + extensions: [new StandardExtension()] + ); - // Calculate UTC offset in seconds - $utcOffset = (string) Carbon::now($timezone)->getOffset(); + // Register all custom filters + $environment->filterRegistry->register(Data::class); + $environment->filterRegistry->register(Date::class); + $environment->filterRegistry->register(Localization::class); + $environment->filterRegistry->register(Numbers::class); + $environment->filterRegistry->register(StringMarkup::class); + $environment->filterRegistry->register(Uniqueness::class); - // Build render context - $context = [ - 'size' => $size, - 'data' => $this->data_payload, - 'config' => $this->configuration ?? [], - ...(is_array($this->data_payload) ? $this->data_payload : []), - 'trmnl' => [ - 'system' => [ - 'timestamp_utc' => now()->utc()->timestamp, - ], - 'user' => [ - 'utc_offset' => $utcOffset, - 'name' => $this->user->name ?? 'Unknown User', - 'locale' => 'en', - 'time_zone_iana' => $timezone, - ], - 'plugin_settings' => [ - 'instance_name' => $this->name, - 'strategy' => $this->data_strategy, - 'dark_mode' => $this->dark_mode ? 'yes' : 'no', - 'no_screen_padding' => $this->no_bleed ? 'yes' : 'no', - 'polling_headers' => $this->polling_header, - 'polling_url' => $this->polling_url, - 'custom_fields_values' => [ - ...(is_array($this->configuration) ? $this->configuration : []), + // 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 : []), + ], ], ], - ], - ]; - - // Check if external renderer should be used - if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) { - // Use external Ruby renderer - pass raw template without preprocessing - $renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context); - } else { - // Use PHP keepsuit/liquid renderer - // Create a custom environment with inline templates support - $inlineFileSystem = new InlineTemplatesFileSystem(); - $environment = new \Keepsuit\Liquid\Environment( - fileSystem: $inlineFileSystem, - extensions: [new StandardExtension(), new LaravelLiquidExtension()] - ); - - // Register all custom filters - $environment->filterRegistry->register(Data::class); - $environment->filterRegistry->register(Date::class); - $environment->filterRegistry->register(Localization::class); - $environment->filterRegistry->register(Numbers::class); - $environment->filterRegistry->register(StringMarkup::class); - $environment->filterRegistry->register(Uniqueness::class); - - // 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); - $liquidContext = $environment->newRenderContext(data: $context); - $renderedContent = $template->render($liquidContext); - } + ] + ); + $renderedContent = $template->render($context); } else { $renderedContent = Blade::render($this->render_markup, [ 'size' => $size, @@ -501,26 +340,9 @@ class Plugin extends Model } if ($standalone) { - if ($size === 'full') { - return view('trmnl-layouts.single', [ - 'colorDepth' => $device?->colorDepth(), - 'deviceVariant' => $device?->deviceVariant() ?? 'og', - 'noBleed' => $this->no_bleed, - 'darkMode' => $this->dark_mode, - 'scaleLevel' => $device?->scaleLevel(), - 'slot' => $renderedContent, - ])->render(); - } - - return view('trmnl-layouts.mashup', [ - 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size), - 'colorDepth' => $device?->colorDepth(), - 'deviceVariant' => $device?->deviceVariant() ?? 'og', - 'darkMode' => $this->dark_mode, - 'scaleLevel' => $device?->scaleLevel(), + return view('trmnl-layouts.single', [ 'slot' => $renderedContent, ])->render(); - } return $renderedContent; @@ -528,30 +350,12 @@ class Plugin extends Model if ($this->render_markup_view) { if ($standalone) { - $renderedView = view($this->render_markup_view, [ - 'size' => $size, - 'data' => $this->data_payload, - 'config' => $this->configuration ?? [], - ])->render(); - - if ($size === 'full') { - return view('trmnl-layouts.single', [ - 'colorDepth' => $device?->colorDepth(), - 'deviceVariant' => $device?->deviceVariant() ?? 'og', - 'noBleed' => $this->no_bleed, - 'darkMode' => $this->dark_mode, - 'scaleLevel' => $device?->scaleLevel(), - 'slot' => $renderedView, - ])->render(); - } - - return view('trmnl-layouts.mashup', [ - 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size), - 'colorDepth' => $device?->colorDepth(), - 'deviceVariant' => $device?->deviceVariant() ?? 'og', - 'darkMode' => $this->dark_mode, - 'scaleLevel' => $device?->scaleLevel(), - 'slot' => $renderedView, + return view('trmnl-layouts.single', [ + 'slot' => view($this->render_markup_view, [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ])->render(), ])->render(); } @@ -573,70 +377,4 @@ class Plugin extends Model { return $this->configuration[$key] ?? $default; } - - public function getPreviewMashupLayoutForSize(string $size): string - { - return match ($size) { - 'half_vertical' => '1Lx1R', - 'quadrant' => '2x2', - 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/app/Models/User.php b/app/Models/User.php index c6d39b8..a1c83ab 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -27,7 +27,6 @@ class User extends Authenticatable // implements MustVerifyEmail 'assign_new_devices', 'assign_new_device_id', 'oidc_sub', - 'timezone', ]; /** diff --git a/app/Notifications/BatteryLow.php b/app/Notifications/BatteryLow.php index 17fb1da..c76c87f 100644 --- a/app/Notifications/BatteryLow.php +++ b/app/Notifications/BatteryLow.php @@ -13,10 +13,15 @@ class BatteryLow extends Notification { use Queueable; + private Device $device; + /** * Create a new notification instance. */ - public function __construct(private Device $device) {} + public function __construct(Device $device) + { + $this->device = $device; + } /** * Get the notification's delivery channels. @@ -36,7 +41,7 @@ class BatteryLow extends Notification return (new MailMessage)->markdown('mail.battery-low', ['device' => $this->device]); } - public function toWebhook(object $notifiable): WebhookMessage + public function toWebhook(object $notifiable) { return WebhookMessage::create() ->data([ diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php index 796cb24..d116200 100644 --- a/app/Notifications/Channels/WebhookChannel.php +++ b/app/Notifications/Channels/WebhookChannel.php @@ -11,7 +11,13 @@ use Illuminate\Support\Arr; class WebhookChannel extends Notification { - public function __construct(protected Client $client) {} + /** @var Client */ + protected $client; + + public function __construct(Client $client) + { + $this->client = $client; + } /** * Send the given notification. diff --git a/app/Notifications/Messages/WebhookMessage.php b/app/Notifications/Messages/WebhookMessage.php index 6dc58eb..920c16d 100644 --- a/app/Notifications/Messages/WebhookMessage.php +++ b/app/Notifications/Messages/WebhookMessage.php @@ -13,6 +13,13 @@ final class WebhookMessage extends Notification */ private $query; + /** + * The POST data of the Webhook request. + * + * @var mixed + */ + private $data; + /** * The headers to send with the request. * @@ -29,8 +36,9 @@ final class WebhookMessage extends Notification /** * @param mixed $data + * @return static */ - public static function create($data = ''): self + public static function create($data = '') { return new self($data); } @@ -38,12 +46,10 @@ final class WebhookMessage extends Notification /** * @param mixed $data */ - public function __construct( - /** - * The POST data of the Webhook request. - */ - private $data = '' - ) {} + public function __construct($data = '') + { + $this->data = $data; + } /** * Set the Webhook parameters to be URL encoded. @@ -51,7 +57,7 @@ final class WebhookMessage extends Notification * @param mixed $query * @return $this */ - public function query($query): self + public function query($query) { $this->query = $query; @@ -64,7 +70,7 @@ final class WebhookMessage extends Notification * @param mixed $data * @return $this */ - public function data($data): self + public function data($data) { $this->data = $data; @@ -78,7 +84,7 @@ final class WebhookMessage extends Notification * @param string $value * @return $this */ - public function header($name, $value): self + public function header($name, $value) { $this->headers[$name] = $value; @@ -91,7 +97,7 @@ final class WebhookMessage extends Notification * @param string $userAgent * @return $this */ - public function userAgent($userAgent): self + public function userAgent($userAgent) { $this->headers['User-Agent'] = $userAgent; @@ -103,14 +109,17 @@ final class WebhookMessage extends Notification * * @return $this */ - public function verify($value = true): self + public function verify($value = true) { $this->verify = $value; return $this; } - public function toArray(): array + /** + * @return array + */ + public function toArray() { return [ 'query' => $this->query, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b8ad9bb..b7deb3b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -33,19 +33,17 @@ class AppServiceProvider extends ServiceProvider $http = clone $this; $http->server->set('HTTPS', 'off'); - if (URL::hasValidSignature($https, $absolute, $ignoreQuery)) { - return true; - } - return URL::hasValidSignature($http, $absolute, $ignoreQuery); + return URL::hasValidSignature($https, $absolute, $ignoreQuery) + || URL::hasValidSignature($http, $absolute, $ignoreQuery); }); // Register OIDC provider with Socialite - Socialite::extend('oidc', function (\Illuminate\Contracts\Foundation\Application $app): OidcProvider { - $config = $app->make('config')->get('services.oidc', []); + Socialite::extend('oidc', function ($app) { + $config = $app['config']['services.oidc'] ?? []; return new OidcProvider( - $app->make(Request::class), + $app['request'], $config['client_id'] ?? null, $config['client_secret'] ?? null, $config['redirect'] ?? null, diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index 405ea3f..137b0af 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -6,33 +6,65 @@ use App\Enums\ImageFormat; use App\Models\Device; use App\Models\DeviceModel; use App\Models\Plugin; -use Bnussbau\TrmnlPipeline\Stages\BrowserStage; -use Bnussbau\TrmnlPipeline\Stages\ImageStage; -use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Exception; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; -use InvalidArgumentException; +use Imagick; +use ImagickException; +use ImagickPixel; +use Log; use Ramsey\Uuid\Uuid; use RuntimeException; +use Spatie\Browsershot\Browsershot; use Wnx\SidecarBrowsershot\BrowsershotLambda; -use function config; -use function file_exists; -use function filesize; - class ImageGenerationService { public static function generateImage(string $markup, $deviceId): string { - $device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId); - $uuid = self::generateImageFromModel( - markup: $markup, - deviceModel: $device->deviceModel, - user: $device->user, - palette: $device->palette ?? $device->deviceModel?->palette, - device: $device - ); + $device = Device::with('deviceModel')->find($deviceId); + $uuid = Uuid::uuid4()->toString(); + $pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png'); + $bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp'); + + // Get image generation settings from DeviceModel if available, otherwise use device settings + $imageSettings = self::getImageSettings($device); + + // Generate PNG + if (config('app.puppeteer_mode') === 'sidecar-aws') { + try { + $browsershot = BrowsershotLambda::html($markup) + ->windowSize(800, 480); + + if (config('app.puppeteer_wait_for_network_idle')) { + $browsershot->waitUntilNetworkIdle(); + } + + $browsershot->save($pngPath); + } catch (Exception $e) { + Log::error('Failed to generate PNG: '.$e->getMessage()); + throw new RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); + } + } else { + try { + $browsershot = Browsershot::html($markup) + ->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []); + if (config('app.puppeteer_wait_for_network_idle')) { + $browsershot->waitUntilNetworkIdle(); + } + if (config('app.puppeteer_window_size_strategy') === 'v2') { + $browsershot->windowSize($imageSettings['width'], $imageSettings['height']); + } else { + $browsershot->windowSize(800, 480); + } + $browsershot->save($pngPath); + } catch (Exception $e) { + Log::error('Failed to generate PNG: '.$e->getMessage()); + throw new RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); + } + } + + // Convert image based on DeviceModel settings or fallback to device settings + self::convertImage($pngPath, $bmpPath, $imageSettings); $device->update(['current_screen_image' => $uuid]); Log::info("Device $device->id: updated with new image: $uuid"); @@ -40,116 +72,6 @@ class ImageGenerationService return $uuid; } - /** - * Generate an image from markup using a DeviceModel - * - * @param string $markup The HTML markup to render - * @param DeviceModel|null $deviceModel The device model to use for image generation - * @param \App\Models\User|null $user Optional user for timezone settings - * @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette - * @param Device|null $device Optional device for legacy devices without DeviceModel - * @return string The UUID of the generated image - */ - public static function generateImageFromModel( - string $markup, - ?DeviceModel $deviceModel = null, - ?\App\Models\User $user = null, - ?\App\Models\DevicePalette $palette = null, - ?Device $device = null - ): string { - $uuid = Uuid::uuid4()->toString(); - - try { - // Get image generation settings from DeviceModel or Device (for legacy devices) - $imageSettings = $deviceModel - ? self::getImageSettingsFromModel($deviceModel) - : ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null)); - - $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png'; - $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); - - // Create custom Browsershot instance if using AWS Lambda - $browsershotInstance = null; - if (config('app.puppeteer_mode') === 'sidecar-aws') { - $browsershotInstance = new BrowsershotLambda(); - } - - $browserStage = new BrowserStage($browsershotInstance); - $browserStage->html($markup); - - // Set timezone from user or fall back to app timezone - $timezone = $user?->timezone ?? config('app.timezone'); - $browserStage->timezone($timezone); - - if (config('app.puppeteer_window_size_strategy') === 'v2') { - $browserStage - ->width($imageSettings['width']) - ->height($imageSettings['height']); - } else { - // default behaviour for Framework v1 - $browserStage->useDefaultDimensions(); - } - - if (config('app.puppeteer_wait_for_network_idle')) { - $browserStage->setBrowsershotOption('waitUntil', 'networkidle0'); - } - - if (config('app.puppeteer_docker')) { - $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']); - } - - // Get palette from parameter or fallback to device model's default palette - $colorPalette = null; - if ($palette && $palette->colors) { - $colorPalette = $palette->colors; - } elseif ($deviceModel?->palette && $deviceModel->palette->colors) { - $colorPalette = $deviceModel->palette->colors; - } - - $imageStage = new ImageStage(); - $imageStage->format($fileExtension) - ->width($imageSettings['width']) - ->height($imageSettings['height']) - ->colors($imageSettings['colors']) - ->bitDepth($imageSettings['bit_depth']) - ->rotation($imageSettings['rotation']) - ->offsetX($imageSettings['offset_x']) - ->offsetY($imageSettings['offset_y']) - ->outputPath($outputPath); - - // Apply color palette if available - if ($colorPalette) { - $imageStage->colormap($colorPalette); - } - - // Apply dithering if requested by markup - $shouldDither = self::markupContainsDitherImage($markup); - if ($shouldDither) { - $imageStage->dither(); - } - - (new TrmnlPipeline())->pipe($browserStage) - ->pipe($imageStage) - ->process(); - - if (! file_exists($outputPath)) { - throw new RuntimeException('Image file was not created: '.$outputPath); - } - - if (filesize($outputPath) === 0) { - throw new RuntimeException('Image file is empty: '.$outputPath); - } - - Log::info("Generated image: $uuid"); - - return $uuid; - - } catch (Exception $e) { - Log::error('Failed to generate image: '.$e->getMessage()); - throw new RuntimeException('Failed to generate image: '.$e->getMessage(), 0, $e); - } - } - /** * Get image generation settings from DeviceModel if available, otherwise use device settings */ @@ -157,63 +79,36 @@ class ImageGenerationService { // If device has a DeviceModel, use its settings if ($device->deviceModel) { - return self::getImageSettingsFromModel($device->deviceModel); - } + /** @var DeviceModel $model */ + $model = $device->deviceModel; - // Fallback to device settings - $imageFormat = $device->image_format ?? ImageFormat::AUTO->value; - $mimeType = self::getMimeTypeFromImageFormat($imageFormat); - $colors = self::getColorsFromImageFormat($imageFormat); - $bitDepth = self::getBitDepthFromImageFormat($imageFormat); - - return [ - 'width' => $device->width ?? 800, - 'height' => $device->height ?? 480, - 'colors' => $colors, - 'bit_depth' => $bitDepth, - 'scale_factor' => 1.0, - 'rotation' => $device->rotate ?? 0, - 'mime_type' => $mimeType, - 'offset_x' => 0, - 'offset_y' => 0, - 'image_format' => $imageFormat, - 'use_model_settings' => false, - ]; - } - - /** - * Get image generation settings from a DeviceModel - */ - private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array - { - if ($deviceModel) { return [ - 'width' => $deviceModel->width, - 'height' => $deviceModel->height, - 'colors' => $deviceModel->colors, - 'bit_depth' => $deviceModel->bit_depth, - 'scale_factor' => $deviceModel->scale_factor, - 'rotation' => $deviceModel->rotation, - 'mime_type' => $deviceModel->mime_type, - 'offset_x' => $deviceModel->offset_x, - 'offset_y' => $deviceModel->offset_y, - 'image_format' => self::determineImageFormatFromModel($deviceModel), + 'width' => $model->width, + 'height' => $model->height, + 'colors' => $model->colors, + 'bit_depth' => $model->bit_depth, + 'scale_factor' => $model->scale_factor, + 'rotation' => $model->rotation, + 'mime_type' => $model->mime_type, + 'offset_x' => $model->offset_x, + 'offset_y' => $model->offset_y, + 'image_format' => self::determineImageFormatFromModel($model), 'use_model_settings' => true, ]; } - // Default settings if no device model provided + // Fallback to device settings return [ - 'width' => 800, - 'height' => 480, + 'width' => $device->width ?? 800, + 'height' => $device->height ?? 480, 'colors' => 2, 'bit_depth' => 1, 'scale_factor' => 1.0, - 'rotation' => 0, + 'rotation' => $device->rotate ?? 0, 'mime_type' => 'image/png', 'offset_x' => 0, 'offset_y' => 0, - 'image_format' => ImageFormat::AUTO->value, + 'image_format' => $device->image_format, 'use_model_settings' => false, ]; } @@ -242,73 +137,197 @@ class ImageGenerationService } /** - * Get MIME type from ImageFormat + * Convert image based on the provided settings */ - private static function getMimeTypeFromImageFormat(string $imageFormat): string + private static function convertImage(string $pngPath, string $bmpPath, array $settings): void { - return match ($imageFormat) { - ImageFormat::BMP3_1BIT_SRGB->value => 'image/bmp', - ImageFormat::PNG_8BIT_GRAYSCALE->value, - ImageFormat::PNG_8BIT_256C->value, - ImageFormat::PNG_2BIT_4C->value => 'image/png', - ImageFormat::AUTO->value => 'image/png', // Default for AUTO - default => 'image/png', - }; - } + $imageFormat = $settings['image_format']; + $useModelSettings = $settings['use_model_settings'] ?? false; - /** - * Get colors from ImageFormat - */ - private static function getColorsFromImageFormat(string $imageFormat): int - { - return match ($imageFormat) { - ImageFormat::BMP3_1BIT_SRGB->value, - ImageFormat::PNG_8BIT_GRAYSCALE->value => 2, - ImageFormat::PNG_8BIT_256C->value => 256, - ImageFormat::PNG_2BIT_4C->value => 4, - ImageFormat::AUTO->value => 2, // Default for AUTO - default => 2, - }; - } - - /** - * Get bit depth from ImageFormat - */ - private static function getBitDepthFromImageFormat(string $imageFormat): int - { - return match ($imageFormat) { - ImageFormat::BMP3_1BIT_SRGB->value, - ImageFormat::PNG_8BIT_GRAYSCALE->value => 1, - ImageFormat::PNG_8BIT_256C->value => 8, - ImageFormat::PNG_2BIT_4C->value => 2, - ImageFormat::AUTO->value => 1, // Default for AUTO - default => 1, - }; - } - - /** - * Detect whether the provided HTML markup contains an tag with class "image-dither". - */ - private static function markupContainsDitherImage(string $markup): bool - { - if (mb_trim($markup) === '') { - return false; + if ($useModelSettings) { + // Use DeviceModel-specific conversion + self::convertUsingModelSettings($pngPath, $bmpPath, $settings); + } else { + // Use legacy device-specific conversion + self::convertUsingLegacySettings($pngPath, $bmpPath, $imageFormat, $settings); } + } - // Find (or with single quotes) and inspect class tokens - $imgWithClassPattern = '/]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i'; - if (! preg_match_all($imgWithClassPattern, $markup, $matches)) { - return false; - } + /** + * Convert image using DeviceModel settings + */ + private static function convertUsingModelSettings(string $pngPath, string $bmpPath, array $settings): void + { + try { + $imagick = new Imagick($pngPath); - foreach ($matches[2] as $classValue) { - // Look for class token 'image-dither' or 'image--dither' - if (preg_match('/(?:^|\s)image--?dither(?:\s|$)/', $classValue)) { - return true; + // Apply scale factor if needed + if ($settings['scale_factor'] !== 1.0) { + $newWidth = (int) ($settings['width'] * $settings['scale_factor']); + $newHeight = (int) ($settings['height'] * $settings['scale_factor']); + $imagick->resizeImage($newWidth, $newHeight, Imagick::FILTER_LANCZOS, 1, true); + } else { + // Resize to model dimensions if different from generated size + if ($imagick->getImageWidth() !== $settings['width'] || $imagick->getImageHeight() !== $settings['height']) { + $imagick->resizeImage($settings['width'], $settings['height'], Imagick::FILTER_LANCZOS, 1, true); + } } + + // Apply rotation + if ($settings['rotation'] !== 0) { + $imagick->rotateImage(new ImagickPixel('black'), $settings['rotation']); + } + + // Apply offset if specified + if ($settings['offset_x'] !== 0 || $settings['offset_y'] !== 0) { + $imagick->rollImage($settings['offset_x'], $settings['offset_y']); + } + + // Handle special case for 4-color, 2-bit PNG + if ($settings['colors'] === 4 && $settings['bit_depth'] === 2 && $settings['mime_type'] === 'image/png') { + self::convertTo4Color2BitPng($imagick, $settings['width'], $settings['height']); + } else { + // Set image type and color depth based on model settings + $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE); + + if ($settings['bit_depth'] === 1) { + $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false); + $imagick->setImageDepth(1); + } else { + $imagick->quantizeImage($settings['colors'], Imagick::COLORSPACE_GRAY, 0, true, false); + $imagick->setImageDepth($settings['bit_depth']); + } + } + + $imagick->stripImage(); + + // Save in the appropriate format + if ($settings['mime_type'] === 'image/bmp') { + $imagick->setFormat('BMP3'); + $imagick->writeImage($bmpPath); + } else { + $imagick->setFormat('png'); + $imagick->writeImage($pngPath); + } + + $imagick->clear(); + } catch (ImagickException $e) { + throw new RuntimeException('Failed to convert image using model settings: '.$e->getMessage(), 0, $e); + } + } + + /** + * Convert image to 4-color, 2-bit PNG using custom colormap and dithering + */ + private static function convertTo4Color2BitPng(Imagick $imagick, int $width, int $height): void + { + // Step 1: Create 4-color grayscale colormap in memory + $colors = ['#000000', '#555555', '#aaaaaa', '#ffffff']; + $colormap = new Imagick(); + + foreach ($colors as $color) { + $swatch = new Imagick(); + $swatch->newImage(1, 1, new ImagickPixel($color)); + $swatch->setImageFormat('png'); + $colormap->addImage($swatch); } - return false; + $colormap = $colormap->appendImages(true); // horizontal + $colormap->setType(Imagick::IMGTYPE_PALETTE); + $colormap->setImageFormat('png'); + + // Step 2: Resize to target dimensions without keeping aspect ratio + $imagick->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1, false); + + // Step 3: Apply Floyd–Steinberg dithering + $imagick->setOption('dither', 'FloydSteinberg'); + + // Step 4: Remap to our 4-color colormap + // $imagick->remapImage($colormap, Imagick::DITHERMETHOD_FLOYDSTEINBERG); + + // Step 5: Force 2-bit grayscale PNG + $imagick->setImageFormat('png'); + $imagick->setImageDepth(2); + $imagick->setType(Imagick::IMGTYPE_GRAYSCALE); + + // Cleanup colormap + $colormap->clear(); + } + + /** + * Convert image using legacy device settings + */ + private static function convertUsingLegacySettings(string $pngPath, string $bmpPath, string $imageFormat, array $settings): void + { + switch ($imageFormat) { + case ImageFormat::BMP3_1BIT_SRGB->value: + try { + self::convertToBmpImageMagick($pngPath, $bmpPath); + } catch (ImagickException $e) { + throw new RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e); + } + break; + case ImageFormat::PNG_8BIT_GRAYSCALE->value: + case ImageFormat::PNG_8BIT_256C->value: + try { + self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation'], quantize: $imageFormat === ImageFormat::PNG_8BIT_GRAYSCALE->value); + } catch (ImagickException $e) { + throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e); + } + break; + case ImageFormat::AUTO->value: + default: + // For AUTO format, we need to check if this is a legacy device + // This would require checking if the device has a firmware version + // For now, we'll use the device's current logic + try { + self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation']); + } catch (ImagickException $e) { + throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e); + } + } + } + + /** + * @throws ImagickException + */ + private static function convertToBmpImageMagick(string $pngPath, string $bmpPath): void + { + $imagick = new Imagick($pngPath); + $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE); + $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false); + $imagick->setImageDepth(1); + $imagick->stripImage(); + $imagick->setFormat('BMP3'); + $imagick->writeImage($bmpPath); + $imagick->clear(); + } + + /** + * @throws ImagickException + */ + private static function convertToPngImageMagick(string $pngPath, ?int $width, ?int $height, ?int $rotate, $quantize = true): void + { + $imagick = new Imagick($pngPath); + if ($width !== 800 || $height !== 480) { + $imagick->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1, true); + } + if ($rotate !== null && $rotate !== 0) { + $imagick->rotateImage(new ImagickPixel('black'), $rotate); + } + + $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE); + $imagick->setOption('dither', 'FloydSteinberg'); + + if ($quantize) { + $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false); + } + $imagick->setImageDepth(8); + $imagick->stripImage(); + + $imagick->setFormat('png'); + $imagick->writeImage($pngPath); + $imagick->clear(); } public static function cleanupFolder(): void @@ -334,20 +353,16 @@ 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 { + ->where(function ($query) { $query->where('width', '!=', 800) ->orWhere('height', '!=', 480) ->orWhere('rotate', '!=', 0); }) - ->orWhereHas('deviceModel', function ($query): void { + ->orWhereHas('deviceModel', function ($query) { // Only allow caching if all device models have standard dimensions (800x480, rotation=0) - $query->where(function ($subQuery): void { + $query->where(function ($subQuery) { $subQuery->where('width', '!=', 800) ->orWhere('height', '!=', 480) ->orWhere('rotation', '!=', 0); @@ -362,180 +377,4 @@ class ImageGenerationService } } } - - /** - * Get device-specific default image path for setup or sleep mode - */ - public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string - { - // Validate image type - if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { - return null; - } - - // If device has a DeviceModel, try to find device-specific image - if ($device->deviceModel) { - $model = $device->deviceModel; - $extension = $model->mime_type === 'image/bmp' ? 'bmp' : 'png'; - $filename = "{$model->width}_{$model->height}_{$model->bit_depth}_{$model->rotation}.{$extension}"; - $deviceSpecificPath = "images/default-screens/{$imageType}_{$filename}"; - - if (Storage::disk('public')->exists($deviceSpecificPath)) { - return $deviceSpecificPath; - } - } - - // Fallback to original hardcoded images - $fallbackPath = "images/{$imageType}.bmp"; - if (Storage::disk('public')->exists($fallbackPath)) { - return $fallbackPath; - } - - // Try PNG fallback - $fallbackPathPng = "images/{$imageType}.png"; - if (Storage::disk('public')->exists($fallbackPathPng)) { - return $fallbackPathPng; - } - - return null; - } - - /** - * Generate a default screen image from Blade template - */ - public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string - { - // Validate image type - if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { - throw new InvalidArgumentException("Invalid image type: {$imageType}"); - } - - $uuid = Uuid::uuid4()->toString(); - - try { - // Load device with relationships - $device->load(['palette', 'deviceModel.palette', 'user']); - - // Get image generation settings from DeviceModel if available, otherwise use device settings - $imageSettings = self::getImageSettings($device); - - $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png'; - $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); - - // Generate HTML from Blade template - $html = self::generateDefaultScreenHtml($device, $imageType, $pluginName); - - // Create custom Browsershot instance if using AWS Lambda - $browsershotInstance = null; - if (config('app.puppeteer_mode') === 'sidecar-aws') { - $browsershotInstance = new BrowsershotLambda(); - } - - $browserStage = new BrowserStage($browsershotInstance); - $browserStage->html($html); - - // Set timezone from user or fall back to app timezone - $timezone = $device->user->timezone ?? config('app.timezone'); - $browserStage->timezone($timezone); - - if (config('app.puppeteer_window_size_strategy') === 'v2') { - $browserStage - ->width($imageSettings['width']) - ->height($imageSettings['height']); - } else { - $browserStage->useDefaultDimensions(); - } - - if (config('app.puppeteer_wait_for_network_idle')) { - $browserStage->setBrowsershotOption('waitUntil', 'networkidle0'); - } - - if (config('app.puppeteer_docker')) { - $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']); - } - - // Get palette from device or fallback to device model's default palette - $palette = $device->palette ?? $device->deviceModel?->palette; - $colorPalette = null; - - if ($palette && $palette->colors) { - $colorPalette = $palette->colors; - } - - $imageStage = new ImageStage(); - $imageStage->format($fileExtension) - ->width($imageSettings['width']) - ->height($imageSettings['height']) - ->colors($imageSettings['colors']) - ->bitDepth($imageSettings['bit_depth']) - ->rotation($imageSettings['rotation']) - ->offsetX($imageSettings['offset_x']) - ->offsetY($imageSettings['offset_y']) - ->outputPath($outputPath); - - // Apply color palette if available - if ($colorPalette) { - $imageStage->colormap($colorPalette); - } - - (new TrmnlPipeline())->pipe($browserStage) - ->pipe($imageStage) - ->process(); - - if (! file_exists($outputPath)) { - throw new RuntimeException('Image file was not created: '.$outputPath); - } - - if (filesize($outputPath) === 0) { - throw new RuntimeException('Image file is empty: '.$outputPath); - } - - Log::info("Device $device->id: generated default screen image: $uuid for type: $imageType"); - - return $uuid; - - } catch (Exception $e) { - Log::error('Failed to generate default screen image: '.$e->getMessage()); - throw new RuntimeException('Failed to generate default screen image: '.$e->getMessage(), 0, $e); - } - } - - /** - * Generate HTML from Blade template for default screens - */ - private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string - { - // Map image type to template name - $templateName = match ($imageType) { - 'setup-logo' => 'default-screens.setup', - 'sleep' => 'default-screens.sleep', - 'error' => 'default-screens.error', - default => throw new InvalidArgumentException("Invalid image type: {$imageType}") - }; - - // Determine device properties from DeviceModel or device settings - $deviceVariant = $device->deviceVariant(); - $deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape'; - $colorDepth = $device->colorDepth() ?? '1bit'; - $scaleLevel = $device->scaleLevel(); - $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode - - // Build view data - $viewData = [ - 'noBleed' => false, - 'darkMode' => $darkMode, - 'deviceVariant' => $deviceVariant, - 'deviceOrientation' => $deviceOrientation, - 'colorDepth' => $colorDepth, - 'scaleLevel' => $scaleLevel, - ]; - - // Add plugin name for error screens - if ($imageType === 'error' && $pluginName !== null) { - $viewData['pluginName'] = $pluginName; - } - - // Render the Blade template - return view($templateName, $viewData)->render(); - } } diff --git a/app/Services/OidcProvider.php b/app/Services/OidcProvider.php index 8ea2e44..e6cda63 100644 --- a/app/Services/OidcProvider.php +++ b/app/Services/OidcProvider.php @@ -33,7 +33,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface /** * Create a new provider instance. */ - public function __construct(\Illuminate\Http\Request $request, $clientId, $clientSecret, $redirectUrl, $scopes = [], $guzzle = []) + public function __construct($request, $clientId, $clientSecret, $redirectUrl, $scopes = [], $guzzle = []) { parent::__construct($request, $clientId, $clientSecret, $redirectUrl, $guzzle); @@ -43,7 +43,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface } // Handle both full well-known URL and base URL - if (str_ends_with((string) $endpoint, '/.well-known/openid-configuration')) { + if (str_ends_with($endpoint, '/.well-known/openid-configuration')) { $this->baseUrl = str_replace('/.well-known/openid-configuration', '', $endpoint); } else { $this->baseUrl = mb_rtrim($endpoint, '/'); @@ -60,7 +60,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface { try { $url = $this->baseUrl.'/.well-known/openid-configuration'; - $client = app(Client::class); + $client = new Client(); $response = $client->get($url); $this->oidcConfig = json_decode($response->getBody()->getContents(), true); @@ -73,7 +73,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface } } catch (Exception $e) { - throw new Exception('Failed to load OIDC configuration: '.$e->getMessage(), $e->getCode(), $e); + throw new Exception('Failed to load OIDC configuration: '.$e->getMessage()); } } @@ -122,7 +122,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface /** * Map the raw user array to a Socialite User instance. */ - public function mapUserToObject(array $user) + protected function mapUserToObject(array $user) { return (new User)->setRaw($user)->map([ 'id' => $user['sub'], diff --git a/app/Services/Plugin/Parsers/IcalResponseParser.php b/app/Services/Plugin/Parsers/IcalResponseParser.php deleted file mode 100644 index c8f2b58..0000000 --- a/app/Services/Plugin/Parsers/IcalResponseParser.php +++ /dev/null @@ -1,111 +0,0 @@ -header('Content-Type'); - $body = $response->body(); - - if (! $this->isIcalResponse($contentType, $body)) { - return null; - } - - try { - $this->parser->parseString($body); - - $events = $this->parser->getEvents()->sorted()->getArrayCopy(); - $windowStart = now()->subDays(7); - $windowEnd = now()->addDays(30); - - $filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool { - $startDate = $this->asCarbon($event['DTSTART'] ?? null); - - if (! $startDate instanceof Carbon) { - return false; - } - - return $startDate->between($windowStart, $windowEnd, true); - })); - - $normalizedEvents = array_map($this->normalizeIcalEvent(...), $filteredEvents); - - return ['ical' => $normalizedEvents]; - } catch (Exception $exception) { - Log::warning('Failed to parse iCal response: '.$exception->getMessage()); - - return ['error' => 'Failed to parse iCal response']; - } - } - - private function isIcalResponse(?string $contentType, string $body): bool - { - $normalizedContentType = $contentType ? mb_strtolower($contentType) : ''; - - if ($normalizedContentType && str_contains($normalizedContentType, 'text/calendar')) { - return true; - } - - return str_contains($body, 'BEGIN:VCALENDAR'); - } - - private function asCarbon(DateTimeInterface|string|null $value): ?Carbon - { - if ($value instanceof Carbon) { - return $value; - } - - if ($value instanceof DateTimeInterface) { - return Carbon::instance($value); - } - - if (is_string($value) && $value !== '') { - try { - return Carbon::parse($value); - } catch (Exception $exception) { - Log::warning('Failed to parse date value: '.$exception->getMessage()); - - return null; - } - } - - return null; - } - - private function normalizeIcalEvent(array $event): array - { - $normalized = []; - - foreach ($event as $key => $value) { - $normalized[$key] = $this->normalizeIcalValue($value); - } - - return $normalized; - } - - private function normalizeIcalValue(mixed $value): mixed - { - if ($value instanceof DateTimeInterface) { - return Carbon::instance($value)->toAtomString(); - } - - if (is_array($value)) { - return array_map($this->normalizeIcalValue(...), $value); - } - - return $value; - } -} diff --git a/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php b/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php deleted file mode 100644 index 44ea0cb..0000000 --- a/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php +++ /dev/null @@ -1,26 +0,0 @@ -json(); - if ($json !== null) { - return $json; - } - - return ['data' => $response->body()]; - } catch (Exception $e) { - Log::warning('Failed to parse JSON response: '.$e->getMessage()); - - return ['error' => 'Failed to parse JSON response']; - } - } -} diff --git a/app/Services/Plugin/Parsers/ResponseParser.php b/app/Services/Plugin/Parsers/ResponseParser.php deleted file mode 100644 index b8f9c05..0000000 --- a/app/Services/Plugin/Parsers/ResponseParser.php +++ /dev/null @@ -1,15 +0,0 @@ - - */ - private readonly array $parsers; - - /** - * @param array $parsers - */ - public function __construct(array $parsers = []) - { - $this->parsers = $parsers ?: [ - new XmlResponseParser(), - new IcalResponseParser(), - new JsonOrTextResponseParser(), - ]; - } - - /** - * @return array - */ - public function getParsers(): array - { - return $this->parsers; - } -} diff --git a/app/Services/Plugin/Parsers/XmlResponseParser.php b/app/Services/Plugin/Parsers/XmlResponseParser.php deleted file mode 100644 index b82ba80..0000000 --- a/app/Services/Plugin/Parsers/XmlResponseParser.php +++ /dev/null @@ -1,46 +0,0 @@ -header('Content-Type'); - - if (! $contentType || ! str_contains(mb_strtolower($contentType), 'xml')) { - return null; - } - - try { - $xml = simplexml_load_string($response->body()); - if ($xml === false) { - throw new Exception('Invalid XML content'); - } - - return ['rss' => $this->xmlToArray($xml)]; - } catch (Exception $exception) { - Log::warning('Failed to parse XML response: '.$exception->getMessage()); - - return ['error' => 'Failed to parse XML response']; - } - } - - private function xmlToArray(SimpleXMLElement $xml): array - { - $array = (array) $xml; - - foreach ($array as $key => $value) { - if ($value instanceof SimpleXMLElement) { - $array[$key] = $this->xmlToArray($value); - } - } - - return $array; - } -} diff --git a/app/Services/PluginExportService.php b/app/Services/PluginExportService.php index 241764d..9f08d76 100644 --- a/app/Services/PluginExportService.php +++ b/app/Services/PluginExportService.php @@ -47,34 +47,44 @@ class PluginExportService $tempDirName = 'temp/'.uniqid('plugin_export_', true); Storage::makeDirectory($tempDirName); $tempDir = Storage::path($tempDirName); - // Generate settings.yml content - $settings = $this->generateSettingsYaml($plugin); - $settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); - File::put($tempDir.'/settings.yml', $settingsYaml); - // Generate full template content - $fullTemplate = $this->generateFullTemplate($plugin); - $extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php'; - File::put($tempDir.'/full.'.$extension, $fullTemplate); - // Generate shared.liquid if needed (for liquid templates) - if ($plugin->markup_language === 'liquid') { - $sharedTemplate = $this->generateSharedTemplate(); - /** @phpstan-ignore-next-line */ - if ($sharedTemplate) { - File::put($tempDir.'/shared.liquid', $sharedTemplate); - } - } - // Create ZIP file - $zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip'; - $zip = new ZipArchive(); - if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { - throw new Exception('Could not create ZIP file.'); - } - // Add files directly to ZIP root - $this->addDirectoryToZip($zip, $tempDir, ''); - $zip->close(); - // Return the ZIP file as a download response - return response()->download($zipPath, 'plugin_'.$plugin->trmnlp_id.'.zip'); + try { + // Generate settings.yml content + $settings = $this->generateSettingsYaml($plugin); + $settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + File::put($tempDir.'/settings.yml', $settingsYaml); + + // Generate full template content + $fullTemplate = $this->generateFullTemplate($plugin); + $extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php'; + File::put($tempDir.'/full.'.$extension, $fullTemplate); + + // Generate shared.liquid if needed (for liquid templates) + if ($plugin->markup_language === 'liquid') { + $sharedTemplate = $this->generateSharedTemplate($plugin); + if ($sharedTemplate) { + File::put($tempDir.'/shared.liquid', $sharedTemplate); + } + } + + // Create ZIP file + $zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip'; + $zip = new ZipArchive(); + + if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { + throw new Exception('Could not create ZIP file.'); + } + + // Add files directly to ZIP root + $this->addDirectoryToZip($zip, $tempDir, ''); + $zip->close(); + + // Return the ZIP file as a download response + return response()->download($zipPath, 'plugin_'.$plugin->trmnlp_id.'.zip'); + + } catch (Exception $e) { + throw $e; + } } /** @@ -140,7 +150,7 @@ class PluginExportService /** * Generate the shared template content (for liquid templates) */ - private function generateSharedTemplate(): null + private function generateSharedTemplate(Plugin $plugin) { // For now, we don't have a way to store shared templates separately // TODO - add support for shared templates @@ -160,10 +170,14 @@ class PluginExportService foreach ($files as $file) { if (! $file->isDir()) { $filePath = $file->getRealPath(); - $fileName = basename((string) $filePath); + $fileName = basename($filePath); // For root directory, just use the filename - $relativePath = $zipPath === '' ? $fileName : $zipPath.'/'.mb_substr((string) $filePath, mb_strlen($dirPath) + 1); + if ($zipPath === '') { + $relativePath = $fileName; + } else { + $relativePath = $zipPath.'/'.mb_substr($filePath, mb_strlen($dirPath) + 1); + } $zip->addFile($filePath, $relativePath); } diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index 49dce99..dbd8ec8 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -7,7 +7,6 @@ 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; @@ -17,45 +16,16 @@ use ZipArchive; class PluginImportService { - /** - * Validate YAML settings - * - * @param array $settings The parsed YAML settings - * - * @throws Exception - */ - private function validateYAML(array $settings): void - { - if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) { - return; - } - - foreach ($settings['custom_fields'] as $field) { - if (isset($field['field_type']) && $field['field_type'] === 'multi_string') { - - if (isset($field['default']) && str_contains($field['default'], ',')) { - throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas."); - } - - if (isset($field['placeholder']) && str_contains($field['placeholder'], ',')) { - throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas."); - } - - } - } - } - /** * Import a plugin from a ZIP file * * @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, ?string $zipEntryPath = null): Plugin + public function importFromZip(UploadedFile $zipFile, User $user): Plugin { // Create a temporary directory using Laravel's temporary directory helper $tempDirName = 'temp/'.uniqid('plugin_import_', true); @@ -75,55 +45,33 @@ class PluginImportService $zip->extractTo($tempDir); $zip->close(); - // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php) - $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); + // 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']) { - throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.'); - } - - // Validate that we have at least one template file - if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) { - throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.'); + 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); - $this->validateYAML($settings); - // Determine which template file to use and read its content - $templatePath = null; + // 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 ($filePaths['fullLiquidPath']) { - $templatePath = $filePaths['fullLiquidPath']; - $fullLiquid = File::get($templatePath); - - // Prepend shared.liquid or shared.blade.php content if available - if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { - $sharedLiquid = File::get($filePaths['sharedLiquidPath']); - $fullLiquid = $sharedLiquid."\n".$fullLiquid; - } elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) { - $sharedBlade = File::get($filePaths['sharedBladePath']); - $fullLiquid = $sharedBlade."\n".$fullLiquid; - } - - // Check if the file ends with .liquid to set markup language - if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') { - $markupLanguage = 'liquid'; - $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; - } - } elseif ($filePaths['sharedLiquidPath']) { - $templatePath = $filePaths['sharedLiquidPath']; - $fullLiquid = File::get($templatePath); + if (pathinfo($filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') { $markupLanguage = 'liquid'; - $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; - } elseif ($filePaths['sharedBladePath']) { - $templatePath = $filePaths['sharedBladePath']; - $fullLiquid = File::get($templatePath); - $markupLanguage = 'blade'; } // Ensure custom_fields is properly formatted @@ -131,9 +79,6 @@ class PluginImportService $settings['custom_fields'] = []; } - // Normalize options in custom_fields (convert non-named values to named values) - $settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']); - // Create configuration template with the custom fields $configurationTemplate = [ 'custom_fields' => $settings['custom_fields'], @@ -187,226 +132,11 @@ class PluginImportService } } - /** - * Import a plugin from a ZIP URL - * - * @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 - * @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid') - * @param string|null $iconUrl Optional icon URL to set on the plugin - * @param bool $allowDuplicate If true, generate a new UUID for trmnlp_id if a plugin with the same trmnlp_id already exists - * @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, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): 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/shared.liquid/shared.blade.php) - $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); - - // Validate that we found the required files - if (! $filePaths['settingsYamlPath']) { - throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.'); - } - - // Validate that we have at least one template file - if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) { - throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.'); - } - - // Parse settings.yml - $settingsYaml = File::get($filePaths['settingsYamlPath']); - $settings = Yaml::parse($settingsYaml); - $this->validateYAML($settings); - - // Determine which template file to use and read its content - $templatePath = null; - $markupLanguage = 'blade'; - - if ($filePaths['fullLiquidPath']) { - $templatePath = $filePaths['fullLiquidPath']; - $fullLiquid = File::get($templatePath); - - // Prepend shared.liquid or shared.blade.php content if available - if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { - $sharedLiquid = File::get($filePaths['sharedLiquidPath']); - $fullLiquid = $sharedLiquid."\n".$fullLiquid; - } elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) { - $sharedBlade = File::get($filePaths['sharedBladePath']); - $fullLiquid = $sharedBlade."\n".$fullLiquid; - } - - // Check if the file ends with .liquid to set markup language - if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') { - $markupLanguage = 'liquid'; - $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; - } - } elseif ($filePaths['sharedLiquidPath']) { - $templatePath = $filePaths['sharedLiquidPath']; - $fullLiquid = File::get($templatePath); - $markupLanguage = 'liquid'; - $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; - } elseif ($filePaths['sharedBladePath']) { - $templatePath = $filePaths['sharedBladePath']; - $fullLiquid = File::get($templatePath); - $markupLanguage = 'blade'; - } - - // Ensure custom_fields is properly formatted - if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) { - $settings['custom_fields'] = []; - } - - // Normalize options in custom_fields (convert non-named values to named values) - $settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']); - - // Create configuration template with the custom fields - $configurationTemplate = [ - 'custom_fields' => $settings['custom_fields'], - ]; - - // Determine the trmnlp_id to use - $trmnlpId = $settings['id'] ?? Uuid::v7(); - - // If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID - if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) { - $trmnlpId = Uuid::v7(); - } - - $plugin_updated = ! $allowDuplicate && 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' => $trmnlpId, - ], - [ - 'user_id' => $user->id, - 'name' => $settings['name'] ?? 'Imported Plugin', - 'trmnlp_id' => $trmnlpId, - '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), - 'preferred_renderer' => $preferredRenderer, - 'icon_url' => $iconUrl, - ]); - - 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); - } - } - - private function findRequiredFiles(string $tempDir, ?string $zipEntryPath = null): array + private function findRequiredFiles(string $tempDir): array { $settingsYamlPath = null; $fullLiquidPath = null; $sharedLiquidPath = null; - $sharedBladePath = 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'; - } elseif (File::exists($targetDir.'/shared.blade.php')) { - $sharedBladePath = $targetDir.'/shared.blade.php'; - } - } - - // 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'; - } elseif (File::exists($targetDir.'/src/shared.blade.php')) { - $sharedBladePath = $targetDir.'/src/shared.blade.php'; - } - } - - // If we found the required files in the target directory, return them - if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) { - return [ - 'settingsYamlPath' => $settingsYamlPath, - 'fullLiquidPath' => $fullLiquidPath, - 'sharedLiquidPath' => $sharedLiquidPath, - 'sharedBladePath' => $sharedBladePath, - ]; - } - } - } // First, check if files are directly in the src folder if (File::exists($tempDir.'/src/settings.yml')) { @@ -419,11 +149,9 @@ class PluginImportService $fullLiquidPath = $tempDir.'/src/full.blade.php'; } - // Check for shared.liquid or shared.blade.php in the same directory + // Check for shared.liquid in the same directory if (File::exists($tempDir.'/src/shared.liquid')) { $sharedLiquidPath = $tempDir.'/src/shared.liquid'; - } elseif (File::exists($tempDir.'/src/shared.blade.php')) { - $sharedBladePath = $tempDir.'/src/shared.blade.php'; } } else { // Search for the files in the extracted directory structure @@ -440,26 +168,19 @@ class PluginImportService $fullLiquidPath = $filepath; } elseif ($filename === 'shared.liquid') { $sharedLiquidPath = $filepath; - } elseif ($filename === 'shared.blade.php') { - $sharedBladePath = $filepath; } - } - // Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid - if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) { - $fullLiquidDir = dirname((string) $fullLiquidPath); - if (File::exists($fullLiquidDir.'/shared.liquid')) { - $sharedLiquidPath = $fullLiquidDir.'/shared.liquid'; - } elseif (File::exists($fullLiquidDir.'/shared.blade.php')) { - $sharedBladePath = $fullLiquidDir.'/shared.blade.php'; + // 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 || $sharedLiquidPath || $sharedBladePath)) { + if ($settingsYamlPath && $fullLiquidPath) { // If the files are in the root of the ZIP, create a src folder and move them there - $srcDir = dirname((string) $settingsYamlPath); + $srcDir = dirname($settingsYamlPath); // If the parent directory is not named 'src', create a src directory if (basename($srcDir) !== 'src') { @@ -468,25 +189,17 @@ class PluginImportService // Copy the files to the src directory File::copy($settingsYamlPath, $newSrcDir.'/settings.yml'); + File::copy($fullLiquidPath, $newSrcDir.'/full.liquid'); - // Copy full.liquid or full.blade.php if it exists - if ($fullLiquidPath) { - $extension = pathinfo((string) $fullLiquidPath, PATHINFO_EXTENSION); - File::copy($fullLiquidPath, $newSrcDir.'/full.'.$extension); - $fullLiquidPath = $newSrcDir.'/full.'.$extension; - } - - // Copy shared.liquid or shared.blade.php if it exists + // Copy shared.liquid if it exists if ($sharedLiquidPath) { File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid'); $sharedLiquidPath = $newSrcDir.'/shared.liquid'; - } elseif ($sharedBladePath) { - File::copy($sharedBladePath, $newSrcDir.'/shared.blade.php'); - $sharedBladePath = $newSrcDir.'/shared.blade.php'; } // Update the paths $settingsYamlPath = $newSrcDir.'/settings.yml'; + $fullLiquidPath = $newSrcDir.'/full.liquid'; } } } @@ -495,104 +208,6 @@ class PluginImportService 'settingsYamlPath' => $settingsYamlPath, 'fullLiquidPath' => $fullLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath, - 'sharedBladePath' => $sharedBladePath, ]; } - - /** - * Normalize options in custom_fields by converting non-named values to named values - * This ensures that options like ["true", "false"] become [["true" => "true"], ["false" => "false"]] - * - * @param array $customFields The custom_fields array from settings - * @return array The normalized custom_fields array - */ - private function normalizeCustomFieldsOptions(array $customFields): array - { - foreach ($customFields as &$field) { - // Only process select fields with options - if (isset($field['field_type']) && $field['field_type'] === 'select' && isset($field['options']) && is_array($field['options'])) { - $normalizedOptions = []; - foreach ($field['options'] as $option) { - // If option is already a named value (array with key-value pair), keep it as is - if (is_array($option)) { - $normalizedOptions[] = $option; - } else { - // Convert non-named value to named value - // Convert boolean to string, use lowercase for label - $value = is_bool($option) ? ($option ? 'true' : 'false') : (string) $option; - $normalizedOptions[] = [$value => $value]; - } - } - $field['options'] = $normalizedOptions; - - // Normalize default value to match normalized option values - if (isset($field['default'])) { - $default = $field['default']; - // If default is boolean, convert to string to match normalized options - if (is_bool($default)) { - $field['default'] = $default ? 'true' : 'false'; - } else { - // Convert to string to ensure consistency - $field['default'] = (string) $default; - } - } - } - } - - return $customFields; - } - - /** - * Validate that template and context are within command-line argument limits - * - * @param string $template The liquid template string - * @param string $jsonContext The JSON-encoded context - * @param string $liquidPath The path to the liquid renderer executable - * - * @throws Exception If the template or context exceeds argument limits - */ - public function validateExternalRendererArguments(string $template, string $jsonContext, string $liquidPath): void - { - // MAX_ARG_STRLEN on Linux is typically 131072 (128KB) for individual arguments - // ARG_MAX is the total size of all arguments (typically 2MB on modern systems) - $maxIndividualArgLength = 131072; // 128KB - MAX_ARG_STRLEN limit - $maxTotalArgLength = $this->getMaxArgumentLength(); - - // Check individual argument sizes (template and context are the largest) - if (mb_strlen($template) > $maxIndividualArgLength || mb_strlen($jsonContext) > $maxIndividualArgLength) { - throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.'); - } - - // Calculate total size of all arguments (path + flags + template + context) - // Add overhead for path, flags, and separators (conservative estimate: ~200 bytes) - $totalArgSize = mb_strlen($liquidPath) + mb_strlen('--template') + mb_strlen($template) - + mb_strlen('--context') + mb_strlen($jsonContext) + 200; - - if ($totalArgSize > $maxTotalArgLength) { - throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.'); - } - } - - /** - * Get the maximum argument length for command-line arguments - * - * @return int Maximum argument length in bytes - */ - private function getMaxArgumentLength(): int - { - // Try to get ARG_MAX from system using getconf - $argMax = null; - if (function_exists('shell_exec')) { - $result = @shell_exec('getconf ARG_MAX 2>/dev/null'); - if ($result !== null && is_numeric(mb_trim($result))) { - $argMax = (int) mb_trim($result); - } - } - - // Use conservative fallback if ARG_MAX cannot be determined - // ARG_MAX on macOS is typically 262144 (256KB), on Linux it's usually 2097152 (2MB) - // We use 200KB as a conservative limit that works on both systems - // Note: ARG_MAX includes environment variables, so we leave headroom - return $argMax !== null ? min($argMax, 204800) : 204800; - } } diff --git a/composer.json b/composer.json index 8903e17..0ae1e49 100644 --- a/composer.json +++ b/composer.json @@ -4,19 +4,17 @@ "type": "project", "description": "TRMNL Server Implementation (BYOS) for Laravel", "keywords": [ - "trmnl", - "trmnl-server", - "trmnl-byos", - "laravel" + "laravel", + "framework", + "trmnl" ], "license": "MIT", "require": { "php": "^8.2", "ext-imagick": "*", - "ext-simplexml": "*", "ext-zip": "*", - "bnussbau/laravel-trmnl-blade": "2.1.*", - "bnussbau/trmnl-pipeline-php": "^0.6.0", + "bnussbau/laravel-trmnl-blade": "1.2.*", + "intervention/image": "^3.11", "keepsuit/laravel-liquid": "^0.5.2", "laravel/framework": "^12.1", "laravel/sanctum": "^4.0", @@ -24,10 +22,7 @@ "laravel/tinker": "^2.10.1", "livewire/flux": "^2.0", "livewire/volt": "^1.7", - "om/icalparser": "^3.2", - "simplesoftwareio/simple-qrcode": "^4.2", "spatie/browsershot": "^5.0", - "stevebauman/purify": "^6.3", "symfony/yaml": "^7.3", "wnx/sidecar-browsershot": "^2.6" }, @@ -42,8 +37,7 @@ "nunomaduro/collision": "^8.6", "pestphp/pest": "^4.0", "pestphp/pest-plugin-drift": "^4.0", - "pestphp/pest-plugin-laravel": "^4.0", - "rector/rector": "^2.1" + "pestphp/pest-plugin-laravel": "^4.0" }, "autoload": { "psr-4": { @@ -79,10 +73,7 @@ ], "test": "vendor/bin/pest", "test-coverage": "vendor/bin/pest --coverage", - "format": "vendor/bin/pint", - "analyse": "vendor/bin/phpstan analyse", - "analyze": "vendor/bin/phpstan analyse", - "rector": "vendor/bin/rector process" + "format": "vendor/bin/pint" }, "extra": { "laravel": { diff --git a/composer.lock b/composer.lock index a469e55..79f70e1 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": "4de5f1df0160f59d08f428e36e81262e", + "content-hash": "ecbc891180c22676e2d03b6c8a02b6e3", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.12", + "version": "3.356.5", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "36ee8894743a254ae2650bad4968c874b76bc7de" + "reference": "5872ccb5100c4afb0dae3db0bd46636f63ae8147" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/36ee8894743a254ae2650bad4968c874b76bc7de", - "reference": "36ee8894743a254ae2650bad4968c874b76bc7de", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/5872ccb5100c4afb0dae3db0bd46636f63ae8147", + "reference": "5872ccb5100c4afb0dae3db0bd46636f63ae8147", "shasum": "" }, "require": { @@ -84,8 +84,7 @@ "guzzlehttp/psr7": "^2.4.5", "mtdowling/jmespath.php": "^2.8.0", "php": ">=8.1", - "psr/http-message": "^1.0 || ^2.0", - "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + "psr/http-message": "^2.0" }, "require-dev": { "andrewsville/php-token-reflection": "^1.4", @@ -96,11 +95,13 @@ "doctrine/cache": "~1.4", "ext-dom": "*", "ext-openssl": "*", + "ext-pcntl": "*", "ext-sockets": "*", - "phpunit/phpunit": "^9.6", + "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", "psr/cache": "^2.0 || ^3.0", "psr/simple-cache": "^2.0 || ^3.0", "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "symfony/filesystem": "^v6.4.0 || ^v7.1.0", "yoast/phpunit-polyfills": "^2.0" }, "suggest": { @@ -108,7 +109,6 @@ "doctrine/cache": "To use the DoctrineCacheAdapter", "ext-curl": "To send requests using cURL", "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", - "ext-pcntl": "To use client-side monitoring", "ext-sockets": "To use client-side monitoring" }, "type": "library", @@ -153,76 +153,22 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.369.12" + "source": "https://github.com/aws/aws-sdk-php/tree/3.356.5" }, - "time": "2026-01-13T19:12:08+00:00" - }, - { - "name": "bacon/bacon-qr-code", - "version": "2.0.8", - "source": { - "type": "git", - "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", - "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", - "shasum": "" - }, - "require": { - "dasprid/enum": "^1.0.3", - "ext-iconv": "*", - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "phly/keep-a-changelog": "^2.1", - "phpunit/phpunit": "^7 | ^8 | ^9", - "spatie/phpunit-snapshot-assertions": "^4.2.9", - "squizlabs/php_codesniffer": "^3.4" - }, - "suggest": { - "ext-imagick": "to generate QR code images" - }, - "type": "library", - "autoload": { - "psr-4": { - "BaconQrCode\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Ben Scholzen 'DASPRiD'", - "email": "mail@dasprids.de", - "homepage": "https://dasprids.de/", - "role": "Developer" - } - ], - "description": "BaconQrCode is a QR code generator for PHP.", - "homepage": "https://github.com/Bacon/BaconQrCode", - "support": { - "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" - }, - "time": "2022-12-07T17:46:57+00:00" + "time": "2025-08-26T18:05:04+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", - "version": "2.1.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/bnussbau/laravel-trmnl-blade.git", - "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7" + "reference": "fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/1e1cabfead00118d7a80c86ac6108aece2989bc7", - "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7", + "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36", + "reference": "fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36", "shasum": "" }, "require": { @@ -277,7 +223,7 @@ ], "support": { "issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues", - "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.1.0" + "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/1.2.1" }, "funding": [ { @@ -293,100 +239,29 @@ "type": "github" } ], - "time": "2026-01-02T20:38:51+00:00" - }, - { - "name": "bnussbau/trmnl-pipeline-php", - "version": "0.6.0", - "source": { - "type": "git", - "url": "https://github.com/bnussbau/trmnl-pipeline-php.git", - "reference": "228505afa8a39a5033916e9ae5ccbf1733092c1f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/bnussbau/trmnl-pipeline-php/zipball/228505afa8a39a5033916e9ae5ccbf1733092c1f", - "reference": "228505afa8a39a5033916e9ae5ccbf1733092c1f", - "shasum": "" - }, - "require": { - "ext-imagick": "*", - "league/pipeline": "^1.0", - "php": "^8.2", - "spatie/browsershot": "^5.0" - }, - "require-dev": { - "laravel/pint": "^1.0", - "pestphp/pest": "^4.0", - "phpstan/phpstan": "^1.10", - "rector/rector": "^1.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Bnussbau\\TrmnlPipeline\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "bnussbau", - "email": "bnussbau@users.noreply.github.com", - "role": "Developer" - } - ], - "description": "Convert HTML content into optimized images for a range of e-ink devices.", - "homepage": "https://github.com/bnussbau/trmnl-pipeline-php", - "keywords": [ - "TRMNL", - "bnussbau", - "e-ink", - "trmnl-pipeline-php" - ], - "support": { - "issues": "https://github.com/bnussbau/trmnl-pipeline-php/issues", - "source": "https://github.com/bnussbau/trmnl-pipeline-php/tree/0.6.0" - }, - "funding": [ - { - "url": "https://www.buymeacoffee.com/bnussbau", - "type": "buy_me_a_coffee" - }, - { - "url": "https://usetrmnl.com/?ref=laravel-trmnl", - "type": "custom" - }, - { - "url": "https://github.com/bnussbau", - "type": "github" - } - ], - "time": "2025-12-02T15:18:51+00:00" + "time": "2025-08-11T16:14:12+00:00" }, { "name": "brick/math", - "version": "0.14.1", + "version": "0.13.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", "shasum": "" }, "require": { - "php": "^8.2" + "php": "^8.1" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpstan/phpstan": "2.1.22", - "phpunit/phpunit": "^11.5" + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "6.8.8" }, "type": "library", "autoload": { @@ -416,7 +291,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.1" + "source": "https://github.com/brick/math/tree/0.13.1" }, "funding": [ { @@ -424,7 +299,7 @@ "type": "github" } ], - "time": "2025-11-24T14:40:29+00:00" + "time": "2025-03-29T13:50:30+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -495,56 +370,6 @@ ], "time": "2024-02-09T16:56:22+00:00" }, - { - "name": "dasprid/enum", - "version": "1.0.7", - "source": { - "type": "git", - "url": "https://github.com/DASPRiD/Enum.git", - "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", - "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", - "shasum": "" - }, - "require": { - "php": ">=7.1 <9.0" - }, - "require-dev": { - "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", - "squizlabs/php_codesniffer": "*" - }, - "type": "library", - "autoload": { - "psr-4": { - "DASPRiD\\Enum\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Ben Scholzen 'DASPRiD'", - "email": "mail@dasprids.de", - "homepage": "https://dasprids.de/", - "role": "Developer" - } - ], - "description": "PHP 7.1 enum implementation", - "keywords": [ - "enum", - "map" - ], - "support": { - "issues": "https://github.com/DASPRiD/Enum/issues", - "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" - }, - "time": "2025-09-16T12:23:56+00:00" - }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -789,28 +614,29 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.6.0", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + "reference": "8c784d071debd117328803d86b2097615b457500" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", - "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", + "reference": "8c784d071debd117328803d86b2097615b457500", "shasum": "" }, "require": { - "php": "^8.2|^8.3|^8.4|^8.5" + "php": "^7.2|^8.0", + "webmozart/assert": "^1.0" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.32|^2.1.31", - "phpunit/phpunit": "^8.5.48|^9.0" + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0" }, "type": "library", "extra": { @@ -841,7 +667,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" }, "funding": [ { @@ -849,7 +675,7 @@ "type": "github" } ], - "time": "2025-10-31T18:51:33+00:00" + "time": "2024-10-09T13:47:03+00:00" }, { "name": "egulias/email-validator", @@ -918,79 +744,18 @@ ], "time": "2025-03-06T22:45:56+00:00" }, - { - "name": "ezyang/htmlpurifier", - "version": "v4.19.0", - "source": { - "type": "git", - "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", - "shasum": "" - }, - "require": { - "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" - }, - "require-dev": { - "cerdic/css-tidy": "^1.7 || ^2.0", - "simpletest/simpletest": "dev-master" - }, - "suggest": { - "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", - "ext-bcmath": "Used for unit conversion and imagecrash protection", - "ext-iconv": "Converts text to and from non-UTF-8 encodings", - "ext-tidy": "Used for pretty-printing HTML" - }, - "type": "library", - "autoload": { - "files": [ - "library/HTMLPurifier.composer.php" - ], - "psr-0": { - "HTMLPurifier": "library/" - }, - "exclude-from-classmap": [ - "/library/HTMLPurifier/Language/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-2.1-or-later" - ], - "authors": [ - { - "name": "Edward Z. Yang", - "email": "admin@htmlpurifier.org", - "homepage": "http://ezyang.com" - } - ], - "description": "Standards compliant HTML filter written in PHP", - "homepage": "http://htmlpurifier.org/", - "keywords": [ - "html" - ], - "support": { - "issues": "https://github.com/ezyang/htmlpurifier/issues", - "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" - }, - "time": "2025-10-17T16:34:55+00:00" - }, { "name": "firebase/php-jwt", - "version": "v7.0.2", + "version": "v6.11.1", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", "shasum": "" }, "require": { @@ -1038,37 +803,37 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" }, - "time": "2025-12-16T22:17:28+00:00" + "time": "2025-04-09T20:32:01+00:00" }, { "name": "fruitcake/php-cors", - "version": "v1.4.0", + "version": "v1.3.0", "source": { "type": "git", "url": "https://github.com/fruitcake/php-cors.git", - "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", - "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", + "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", "shasum": "" }, "require": { - "php": "^8.1", - "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + "php": "^7.4|^8.0", + "symfony/http-foundation": "^4.4|^5.4|^6|^7" }, "require-dev": { - "phpstan/phpstan": "^2", + "phpstan/phpstan": "^1.4", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^4" + "squizlabs/php_codesniffer": "^3.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.3-dev" + "dev-master": "1.2-dev" } }, "autoload": { @@ -1099,7 +864,7 @@ ], "support": { "issues": "https://github.com/fruitcake/php-cors/issues", - "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" }, "funding": [ { @@ -1111,28 +876,28 @@ "type": "github" } ], - "time": "2025-12-03T09:33:47+00:00" + "time": "2023-10-12T05:21:21+00:00" }, { "name": "graham-campbell/result-type", - "version": "v1.1.4", + "version": "v1.1.3", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "phpoption/phpoption": "^1.9.3" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" }, "type": "library", "autoload": { @@ -1161,7 +926,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" }, "funding": [ { @@ -1173,7 +938,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2024-07-20T21:45:45+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1644,6 +1409,150 @@ }, "time": "2025-08-22T14:58:51+00:00" }, + { + "name": "intervention/gif", + "version": "4.2.2", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "5999eac6a39aa760fb803bc809e8909ee67b451a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/5999eac6a39aa760fb803bc809e8909ee67b451a", + "reference": "5999eac6a39aa760fb803bc809e8909ee67b451a", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "Native PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/4.2.2" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2025-03-29T07:46:21+00:00" + }, + { + "name": "intervention/image", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "8c49eb21a6d2572532d1bc425964264f3e496846" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/8c49eb21a6d2572532d1bc425964264f3e496846", + "reference": "8c49eb21a6d2572532d1bc425964264f3e496846", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^4.2", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "PHP image manipulation", + "homepage": "https://image.intervention.io/", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/3.11.4" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2025-07-30T13:13:19+00:00" + }, { "name": "keepsuit/laravel-liquid", "version": "v0.5.4", @@ -1721,16 +1630,16 @@ }, { "name": "keepsuit/liquid", - "version": "v0.9.1", + "version": "v0.9.0", "source": { "type": "git", "url": "https://github.com/keepsuit/php-liquid.git", - "reference": "844d88540524f99d9039916e0ef688b7f222ebc0" + "reference": "f5d81df3689acb79b04c7be3d13778e1f138185f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/keepsuit/php-liquid/zipball/844d88540524f99d9039916e0ef688b7f222ebc0", - "reference": "844d88540524f99d9039916e0ef688b7f222ebc0", + "url": "https://api.github.com/repos/keepsuit/php-liquid/zipball/f5d81df3689acb79b04c7be3d13778e1f138185f", + "reference": "f5d81df3689acb79b04c7be3d13778e1f138185f", "shasum": "" }, "require": { @@ -1739,17 +1648,17 @@ }, "require-dev": { "laravel/pint": "^1.2", - "pestphp/pest": "^2.36 || ^3.0 || ^4.0", - "pestphp/pest-plugin-arch": "^2.7 || ^3.0 || ^4.0", + "pestphp/pest": "^2.36 || ^3.0", + "pestphp/pest-plugin-arch": "^2.7 || ^3.0", "phpbench/phpbench": "dev-master", "phpstan/extension-installer": "^1.3", "phpstan/phpstan": "^2.0", "phpstan/phpstan-deprecation-rules": "^2.0", "spatie/invade": "^2.0", "spatie/ray": "^1.28", - "symfony/console": "^6.1 || ^7.0 || ^8.0", - "symfony/var-exporter": "^6.1 || ^7.0 || ^8.0", - "symfony/yaml": "^6.1 || ^7.0 || ^8.0" + "symfony/console": "^6.1 || ^7.0", + "symfony/var-exporter": "^6.1 || ^7.0", + "symfony/yaml": "^6.1 || ^7.0" }, "type": "library", "autoload": { @@ -1776,26 +1685,26 @@ ], "support": { "issues": "https://github.com/keepsuit/php-liquid/issues", - "source": "https://github.com/keepsuit/php-liquid/tree/v0.9.1" + "source": "https://github.com/keepsuit/php-liquid/tree/v0.9.0" }, - "time": "2025-12-01T12:01:51+00:00" + "time": "2025-06-15T12:02:45+00:00" }, { "name": "laravel/framework", - "version": "v12.47.0", + "version": "v12.26.2", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "ab8114c2e78f32e64eb238fc4b495bea3f8b80ec" + "reference": "56c5fc46cfb1005d0aaa82c7592d63edb776a787" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/ab8114c2e78f32e64eb238fc4b495bea3f8b80ec", - "reference": "ab8114c2e78f32e64eb238fc4b495bea3f8b80ec", + "url": "https://api.github.com/repos/laravel/framework/zipball/56c5fc46cfb1005d0aaa82c7592d63edb776a787", + "reference": "56c5fc46cfb1005d0aaa82c7592d63edb776a787", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.11|^0.12|^0.13", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -1831,8 +1740,8 @@ "symfony/http-kernel": "^7.2.0", "symfony/mailer": "^7.2.0", "symfony/mime": "^7.2.0", - "symfony/polyfill-php83": "^1.33", - "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php83": "^1.31", + "symfony/polyfill-php84": "^1.31", "symfony/polyfill-php85": "^1.33", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", @@ -1869,7 +1778,6 @@ "illuminate/filesystem": "self.version", "illuminate/hashing": "self.version", "illuminate/http": "self.version", - "illuminate/json-schema": "self.version", "illuminate/log": "self.version", "illuminate/macroable": "self.version", "illuminate/mail": "self.version", @@ -1879,7 +1787,6 @@ "illuminate/process": "self.version", "illuminate/queue": "self.version", "illuminate/redis": "self.version", - "illuminate/reflection": "self.version", "illuminate/routing": "self.version", "illuminate/session": "self.version", "illuminate/support": "self.version", @@ -1903,14 +1810,13 @@ "league/flysystem-read-only": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", - "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.8.1", + "orchestra/testbench-core": "^10.6.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", "predis/predis": "^2.3|^3.0", - "resend/resend-php": "^0.10.0|^1.0", + "resend/resend-php": "^0.10.0", "symfony/cache": "^7.2.0", "symfony/http-client": "^7.2.0", "symfony/psr-http-message-bridge": "^7.2.0", @@ -1929,7 +1835,7 @@ "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", - "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", "laravel/tinker": "Required to use the tinker console command (^2.0).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", @@ -1944,7 +1850,7 @@ "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", @@ -1966,7 +1872,6 @@ "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", "src/Illuminate/Log/functions.php", - "src/Illuminate/Reflection/helpers.php", "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], @@ -1975,8 +1880,7 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/", - "src/Illuminate/Reflection/" + "src/Illuminate/Conditionable/" ] } }, @@ -2000,20 +1904,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-13T15:29:06+00:00" + "time": "2025-08-26T18:04:56+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.9", + "version": "v0.3.6", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "5c41bf0555b7cfefaad4e66d3046675829581ac4" + "reference": "86a8b692e8661d0fb308cec64f3d176821323077" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/5c41bf0555b7cfefaad4e66d3046675829581ac4", - "reference": "5c41bf0555b7cfefaad4e66d3046675829581ac4", + "url": "https://api.github.com/repos/laravel/prompts/zipball/86a8b692e8661d0fb308cec64f3d176821323077", + "reference": "86a8b692e8661d0fb308cec64f3d176821323077", "shasum": "" }, "require": { @@ -2029,9 +1933,9 @@ "require-dev": { "illuminate/collections": "^10.0|^11.0|^12.0", "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3|^3.4|^4.0", - "phpstan/phpstan": "^1.12.28", - "phpstan/phpstan-mockery": "^1.1.3" + "pestphp/pest": "^2.3|^3.4", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-mockery": "^1.1" }, "suggest": { "ext-pcntl": "Required for the spinner to be animated." @@ -2057,22 +1961,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.9" + "source": "https://github.com/laravel/prompts/tree/v0.3.6" }, - "time": "2026-01-07T21:00:29+00:00" + "time": "2025-07-07T14:17:42+00:00" }, { "name": "laravel/sanctum", - "version": "v4.2.3", + "version": "v4.2.0", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "47d26f1d310879ff757b971f5a6fc631d18663fd" + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/47d26f1d310879ff757b971f5a6fc631d18663fd", - "reference": "47d26f1d310879ff757b971f5a6fc631d18663fd", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", "shasum": "" }, "require": { @@ -2086,8 +1990,9 @@ }, "require-dev": { "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.15|^10.8", - "phpstan/phpstan": "^1.10" + "orchestra/testbench": "^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { @@ -2122,20 +2027,20 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-01-11T18:20:25+00:00" + "time": "2025-07-09T19:45:24+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.8", + "version": "v2.0.4", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" + "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b352cf0534aa1ae6b4d825d1e762e35d43f8a841", + "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841", "shasum": "" }, "require": { @@ -2144,7 +2049,7 @@ "require-dev": { "illuminate/support": "^10.0|^11.0|^12.0", "nesbot/carbon": "^2.67|^3.0", - "pestphp/pest": "^2.36|^3.0|^4.0", + "pestphp/pest": "^2.36|^3.0", "phpstan/phpstan": "^2.0", "symfony/var-dumper": "^6.2.0|^7.0.0" }, @@ -2183,25 +2088,25 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-01-08T16:22:46+00:00" + "time": "2025-03-19T13:51:03+00:00" }, { "name": "laravel/socialite", - "version": "v5.24.2", + "version": "v5.23.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613" + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", + "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", "shasum": "" }, "require": { "ext-json": "*", - "firebase/php-jwt": "^6.4|^7.0", + "firebase/php-jwt": "^6.4", "guzzlehttp/guzzle": "^6.0|^7.0", "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", @@ -2212,9 +2117,9 @@ }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", "phpstan/phpstan": "^1.12.23", - "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5" }, "type": "library", "extra": { @@ -2255,20 +2160,20 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-01-10T16:07:28+00:00" + "time": "2025-07-23T14:16:08+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.0", + "version": "v2.10.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" + "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", + "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3", + "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3", "shasum": "" }, "require": { @@ -2277,7 +2182,7 @@ "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^7.2.5|^8.0", "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", @@ -2319,22 +2224,22 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.0" + "source": "https://github.com/laravel/tinker/tree/v2.10.1" }, - "time": "2025-12-19T19:16:45+00:00" + "time": "2025-01-27T14:24:01+00:00" }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.7.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", "shasum": "" }, "require": { @@ -2371,7 +2276,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.8-dev" } }, "autoload": { @@ -2428,7 +2333,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2025-07-20T12:47:49+00:00" }, { "name": "league/config", @@ -2514,16 +2419,16 @@ }, { "name": "league/flysystem", - "version": "3.30.2", + "version": "3.30.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277" + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", - "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", "shasum": "" }, "require": { @@ -2591,22 +2496,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" }, - "time": "2025-11-10T17:13:11+00:00" + "time": "2025-06-25T13:29:59+00:00" }, { "name": "league/flysystem-local", - "version": "3.30.2", + "version": "3.30.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d" + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d", - "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", "shasum": "" }, "require": { @@ -2640,9 +2545,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" }, - "time": "2025-11-10T11:23:37+00:00" + "time": "2025-05-21T10:34:19+00:00" }, { "name": "league/mime-type-detection", @@ -2776,96 +2681,35 @@ }, "time": "2024-12-10T19:59:05+00:00" }, - { - "name": "league/pipeline", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/thephpleague/pipeline.git", - "reference": "9069ddfdbd5582f8a563e00cffdbeffb9a0acd01" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thephpleague/pipeline/zipball/9069ddfdbd5582f8a563e00cffdbeffb9a0acd01", - "reference": "9069ddfdbd5582f8a563e00cffdbeffb9a0acd01", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.0 || ^10.0 || ^11.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "League\\Pipeline\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Frank de Jonge", - "email": "info@frenky.net", - "role": "Author" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com", - "role": "Maintainer" - } - ], - "description": "A plug and play pipeline implementation.", - "keywords": [ - "composition", - "design pattern", - "pattern", - "pipeline", - "sequential" - ], - "support": { - "issues": "https://github.com/thephpleague/pipeline/issues", - "source": "https://github.com/thephpleague/pipeline/tree/1.1.0" - }, - "time": "2025-02-06T08:48:15+00:00" - }, { "name": "league/uri", - "version": "7.7.0", + "version": "7.5.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" + "reference": "81fb5145d2644324614cc532b28efd0215bda430" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", + "reference": "81fb5145d2644324614cc532b28efd0215bda430", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.7", - "php": "^8.1", - "psr/http-factory": "^1" + "league/uri-interfaces": "^7.5", + "php": "^8.1" }, "conflict": { "league/uri-schemes": "^1.0" }, "suggest": { "ext-bcmath": "to improve IPV4 host parsing", - "ext-dom": "to convert the URI into an HTML anchor tag", "ext-fileinfo": "to create Data URI from file contennts", "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", - "ext-uri": "to use the PHP native URI class", "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", "league/uri-components": "Needed to easily manipulate URI objects components", - "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2893,7 +2737,6 @@ "description": "URI manipulation library", "homepage": "https://uri.thephpleague.com", "keywords": [ - "URN", "data-uri", "file-uri", "ftp", @@ -2906,11 +2749,9 @@ "psr-7", "query-string", "querystring", - "rfc2141", "rfc3986", "rfc3987", "rfc6570", - "rfc8141", "uri", "uri-template", "url", @@ -2920,7 +2761,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.7.0" + "source": "https://github.com/thephpleague/uri/tree/7.5.1" }, "funding": [ { @@ -2928,25 +2769,26 @@ "type": "github" } ], - "time": "2025-12-07T16:02:06+00:00" + "time": "2024-12-08T08:40:02+00:00" }, { "name": "league/uri-interfaces", - "version": "7.7.0", + "version": "7.5.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", "shasum": "" }, "require": { "ext-filter": "*", "php": "^8.1", + "psr/http-factory": "^1", "psr/http-message": "^1.1 || ^2.0" }, "suggest": { @@ -2954,7 +2796,6 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2979,7 +2820,7 @@ "homepage": "https://nyamsprod.com" } ], - "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "description": "Common interfaces and classes for URI representation and interaction", "homepage": "https://uri.thephpleague.com", "keywords": [ "data-uri", @@ -3004,7 +2845,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" }, "funding": [ { @@ -3012,20 +2853,20 @@ "type": "github" } ], - "time": "2025-12-07T16:03:21+00:00" + "time": "2024-12-08T08:18:47+00:00" }, { "name": "livewire/flux", - "version": "v2.10.2", + "version": "v2.2.6", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975" + "reference": "a0e8f33a5cd54ead4d8e27721961425ccbae2e33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/e7a93989788429bb6c0a908a056d22ea3a6c7975", - "reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975", + "url": "https://api.github.com/repos/livewire/flux/zipball/a0e8f33a5cd54ead4d8e27721961425ccbae2e33", + "reference": "a0e8f33a5cd54ead4d8e27721961425ccbae2e33", "shasum": "" }, "require": { @@ -3033,12 +2874,12 @@ "illuminate/support": "^10.0|^11.0|^12.0", "illuminate/view": "^10.0|^11.0|^12.0", "laravel/prompts": "^0.1|^0.2|^0.3", - "livewire/livewire": "^3.7.3|^4.0", + "livewire/livewire": "^3.5.19", "php": "^8.1", "symfony/console": "^6.0|^7.0" }, "conflict": { - "livewire/blaze": "<1.0.0" + "livewire/blaze": "<0.1.0" }, "type": "library", "extra": { @@ -3076,22 +2917,22 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.10.2" + "source": "https://github.com/livewire/flux/tree/v2.2.6" }, - "time": "2025-12-19T02:11:45+00:00" + "time": "2025-08-27T02:05:01+00:00" }, { "name": "livewire/livewire", - "version": "v3.7.3", + "version": "v3.6.4", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "a5384df9fbd3eaf02e053bc49aabc8ace293fc1c" + "reference": "ef04be759da41b14d2d129e670533180a44987dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/a5384df9fbd3eaf02e053bc49aabc8ace293fc1c", - "reference": "a5384df9fbd3eaf02e053bc49aabc8ace293fc1c", + "url": "https://api.github.com/repos/livewire/livewire/zipball/ef04be759da41b14d2d129e670533180a44987dc", + "reference": "ef04be759da41b14d2d129e670533180a44987dc", "shasum": "" }, "require": { @@ -3146,7 +2987,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.3" + "source": "https://github.com/livewire/livewire/tree/v3.6.4" }, "funding": [ { @@ -3154,31 +2995,32 @@ "type": "github" } ], - "time": "2025-12-19T02:00:29+00:00" + "time": "2025-07-17T05:12:15+00:00" }, { "name": "livewire/volt", - "version": "v1.10.1", + "version": "v1.7.2", "source": { "type": "git", "url": "https://github.com/livewire/volt.git", - "reference": "48cff133990c6261c63ee279fc091af6f6c6654e" + "reference": "91ba934e72bbd162442840862959ade24dbe728a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/volt/zipball/48cff133990c6261c63ee279fc091af6f6c6654e", - "reference": "48cff133990c6261c63ee279fc091af6f6c6654e", + "url": "https://api.github.com/repos/livewire/volt/zipball/91ba934e72bbd162442840862959ade24dbe728a", + "reference": "91ba934e72bbd162442840862959ade24dbe728a", "shasum": "" }, "require": { "laravel/framework": "^10.38.2|^11.0|^12.0", - "livewire/livewire": "^3.6.1|^4.0", + "livewire/livewire": "^3.6.1", "php": "^8.1" }, "require-dev": { "laravel/folio": "^1.1", - "orchestra/testbench": "^8.36|^9.15|^10.8", - "pestphp/pest": "^2.9.5|^3.0|^4.0", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.15.0|^9.0|^10.0", + "pestphp/pest": "^2.9.5|^3.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -3225,20 +3067,20 @@ "issues": "https://github.com/livewire/volt/issues", "source": "https://github.com/livewire/volt" }, - "time": "2025-11-25T16:19:15+00:00" + "time": "2025-08-06T15:40:50+00:00" }, { "name": "maennchen/zipstream-php", - "version": "3.2.1", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416", "shasum": "" }, "require": { @@ -3249,7 +3091,7 @@ "require-dev": { "brianium/paratest": "^7.7", "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.86", + "friendsofphp/php-cs-fixer": "^3.16", "guzzlehttp/guzzle": "^7.5", "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.5", @@ -3295,7 +3137,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0" }, "funding": [ { @@ -3303,20 +3145,20 @@ "type": "github" } ], - "time": "2025-12-10T09:58:31+00:00" + "time": "2025-07-17T11:15:13+00:00" }, { "name": "monolog/monolog", - "version": "3.10.0", + "version": "3.9.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", "shasum": "" }, "require": { @@ -3334,7 +3176,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8 || ^2.0", + "mongodb/mongodb": "^1.8", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -3394,7 +3236,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" }, "funding": [ { @@ -3406,7 +3248,7 @@ "type": "tidelift" } ], - "time": "2026-01-02T08:56:05+00:00" + "time": "2025-03-24T10:02:05+00:00" }, { "name": "mtdowling/jmespath.php", @@ -3476,16 +3318,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.0", + "version": "3.10.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1" + "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", + "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", "shasum": "" }, "require": { @@ -3493,9 +3335,9 @@ "ext-json": "*", "php": "^8.1", "psr/clock": "^1.0", - "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/clock": "^6.3.12 || ^7.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -3503,13 +3345,13 @@ "require-dev": { "doctrine/dbal": "^3.6.3 || ^4.0", "doctrine/orm": "^2.15.2 || ^3.0", - "friendsofphp/php-cs-fixer": "^v3.87.1", + "friendsofphp/php-cs-fixer": "^3.75.0", "kylekatarnls/multi-tester": "^2.5.3", "phpmd/phpmd": "^2.15.0", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.22", - "phpunit/phpunit": "^10.5.53", - "squizlabs/php_codesniffer": "^3.13.4" + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^10.5.46", + "squizlabs/php_codesniffer": "^3.13.0" }, "bin": [ "bin/carbon" @@ -3577,29 +3419,29 @@ "type": "tidelift" } ], - "time": "2025-12-02T21:04:28+00:00" + "time": "2025-08-02T09:36:06+00:00" }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.2", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "da801d52f0354f70a638673c4a0f04e16529431d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", + "reference": "da801d52f0354f70a638673c4a0f04e16529431d", "shasum": "" }, "require": { "nette/utils": "^4.0", - "php": "8.1 - 8.5" + "php": "8.1 - 8.4" }, "require-dev": { "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/phpstan-nette": "^1.0", "tracy/tracy": "^2.8" }, "type": "library", @@ -3609,9 +3451,6 @@ } }, "autoload": { - "psr-4": { - "Nette\\": "src" - }, "classmap": [ "src/" ] @@ -3640,26 +3479,26 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.2" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2024-10-06T23:10:23+00:00" }, { "name": "nette/utils", - "version": "v4.1.1", + "version": "v4.0.8", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" + "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", + "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede", + "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede", "shasum": "" }, "require": { - "php": "8.2 - 8.5" + "php": "8.0 - 8.5" }, "conflict": { "nette/finder": "<3", @@ -3682,7 +3521,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.1-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -3729,22 +3568,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.1" + "source": "https://github.com/nette/utils/tree/v4.0.8" }, - "time": "2025-12-22T12:14:32+00:00" + "time": "2025-08-06T21:43:34+00:00" }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.6.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", "shasum": "" }, "require": { @@ -3787,37 +3626,37 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2025-08-13T20:13:15+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.3.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.2.6" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.44.7", + "laravel/pint": "^1.22.0", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", - "phpstan/phpstan": "^1.12.32", + "pestphp/pest": "^2.36.0 || ^3.8.2", + "phpstan/phpstan": "^1.12.25", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.2.6", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3860,7 +3699,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" }, "funding": [ { @@ -3876,81 +3715,28 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" - }, - { - "name": "om/icalparser", - "version": "v3.2.1", - "source": { - "type": "git", - "url": "https://github.com/OzzyCzech/icalparser.git", - "reference": "bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/OzzyCzech/icalparser/zipball/bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc", - "reference": "bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc", - "shasum": "" - }, - "require": { - "php": ">=8.1.0" - }, - "require-dev": { - "nette/tester": "^2.5.7" - }, - "suggest": { - "ext-dom": "for timezone tool" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Roman Ožana", - "email": "roman@ozana.cz" - } - ], - "description": "Simple iCal parser", - "keywords": [ - "calendar", - "ical", - "parser" - ], - "support": { - "issues": "https://github.com/OzzyCzech/icalparser/issues", - "source": "https://github.com/OzzyCzech/icalparser/tree/v3.2.1" - }, - "time": "2025-12-15T06:25:09+00:00" + "time": "2025-05-08T08:14:37+00:00" }, { "name": "paragonie/constant_time_encoding", - "version": "v3.1.3", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512", "shasum": "" }, "require": { "php": "^8" }, "require-dev": { - "infection/infection": "^0", - "nikic/php-fuzzer": "^0", - "phpunit/phpunit": "^9|^10|^11", - "vimeo/psalm": "^4|^5|^6" + "phpunit/phpunit": "^9", + "vimeo/psalm": "^4|^5" }, "type": "library", "autoload": { @@ -3996,7 +3782,7 @@ "issues": "https://github.com/paragonie/constant_time_encoding/issues", "source": "https://github.com/paragonie/constant_time_encoding" }, - "time": "2025-09-24T15:06:41+00:00" + "time": "2024-05-08T12:36:18+00:00" }, { "name": "paragonie/random_compat", @@ -4050,16 +3836,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.5", + "version": "1.9.4", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", "shasum": "" }, "require": { @@ -4109,7 +3895,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" }, "funding": [ { @@ -4121,20 +3907,20 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2025-08-21T11:53:16+00:00" }, { "name": "phpseclib/phpseclib", - "version": "3.0.48", + "version": "3.0.46", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" + "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", - "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", + "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", "shasum": "" }, "require": { @@ -4215,7 +4001,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.46" }, "funding": [ { @@ -4231,7 +4017,7 @@ "type": "tidelift" } ], - "time": "2025-12-15T11:51:42+00:00" + "time": "2025-06-26T16:29:55+00:00" }, { "name": "psr/clock", @@ -4647,16 +4433,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.18", + "version": "v0.12.10", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/6e80abe6f2257121f1eb9a4c55bf29d921025b22", + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22", "shasum": "" }, "require": { @@ -4664,19 +4450,18 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.4", - "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2", - "composer/class-map-generator": "^1.6" + "bamarni/composer-bin-plugin": "^1.2" }, "suggest": { - "composer/class-map-generator": "Improved tab completion performance with better class discovery.", "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-pdo-sqlite": "The doc command requires SQLite to work.", "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." }, "bin": [ @@ -4720,9 +4505,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.10" }, - "time": "2025-12-17T14:35:46+00:00" + "time": "2025-08-04T12:39:37+00:00" }, { "name": "ralouphie/getallheaders", @@ -4846,20 +4631,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.2", + "version": "4.9.0", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4918,90 +4703,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.0" }, - "time": "2025-12-14T04:43:48+00:00" - }, - { - "name": "simplesoftwareio/simple-qrcode", - "version": "4.2.0", - "source": { - "type": "git", - "url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git", - "reference": "916db7948ca6772d54bb617259c768c9cdc8d537" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537", - "reference": "916db7948ca6772d54bb617259c768c9cdc8d537", - "shasum": "" - }, - "require": { - "bacon/bacon-qr-code": "^2.0", - "ext-gd": "*", - "php": ">=7.2|^8.0" - }, - "require-dev": { - "mockery/mockery": "~1", - "phpunit/phpunit": "~9" - }, - "suggest": { - "ext-imagick": "Allows the generation of PNG QrCodes.", - "illuminate/support": "Allows for use within Laravel." - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode" - }, - "providers": [ - "SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "SimpleSoftwareIO\\QrCode\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Simple Software LLC", - "email": "support@simplesoftware.io" - } - ], - "description": "Simple QrCode is a QR code generator made for Laravel.", - "homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode", - "keywords": [ - "Simple", - "generator", - "laravel", - "qrcode", - "wrapper" - ], - "support": { - "issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues", - "source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0" - }, - "time": "2021-02-08T20:43:55+00:00" + "time": "2025-06-25T14:20:11+00:00" }, { "name": "spatie/browsershot", - "version": "5.2.0", + "version": "5.0.10", "source": { "type": "git", "url": "https://github.com/spatie/browsershot.git", - "reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8" + "reference": "9e5ae15487b3cdc3eb03318c1c8ac38971f60e58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/browsershot/zipball/9bc6b8d67175810d7a399b2588c3401efe2d02a8", - "reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8", + "url": "https://api.github.com/repos/spatie/browsershot/zipball/9e5ae15487b3cdc3eb03318c1c8ac38971f60e58", + "reference": "9e5ae15487b3cdc3eb03318c1c8ac38971f60e58", "shasum": "" }, "require": { @@ -5009,13 +4726,13 @@ "ext-json": "*", "php": "^8.2", "spatie/temporary-directory": "^2.0", - "symfony/process": "^6.0|^7.0|^8.0" + "symfony/process": "^6.0|^7.0" }, "require-dev": { - "pestphp/pest": "^3.0|^4.0", + "pestphp/pest": "^3.0", "spatie/image": "^3.6", "spatie/pdf-to-text": "^1.52", - "spatie/phpunit-snapshot-assertions": "^5.0" + "spatie/phpunit-snapshot-assertions": "^4.2.3|^5.0" }, "type": "library", "autoload": { @@ -5048,7 +4765,7 @@ "webpage" ], "support": { - "source": "https://github.com/spatie/browsershot/tree/5.2.0" + "source": "https://github.com/spatie/browsershot/tree/5.0.10" }, "funding": [ { @@ -5056,7 +4773,7 @@ "type": "github" } ], - "time": "2025-12-22T10:02:16+00:00" + "time": "2025-05-15T07:10:57+00:00" }, { "name": "spatie/laravel-package-tools", @@ -5121,16 +4838,16 @@ }, { "name": "spatie/temporary-directory", - "version": "2.3.1", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/spatie/temporary-directory.git", - "reference": "662e481d6ec07ef29fd05010433428851a42cd07" + "reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/662e481d6ec07ef29fd05010433428851a42cd07", - "reference": "662e481d6ec07ef29fd05010433428851a42cd07", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/580eddfe9a0a41a902cac6eeb8f066b42e65a32b", + "reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b", "shasum": "" }, "require": { @@ -5166,7 +4883,7 @@ ], "support": { "issues": "https://github.com/spatie/temporary-directory/issues", - "source": "https://github.com/spatie/temporary-directory/tree/2.3.1" + "source": "https://github.com/spatie/temporary-directory/tree/2.3.0" }, "funding": [ { @@ -5178,91 +4895,26 @@ "type": "github" } ], - "time": "2026-01-12T07:42:22+00:00" - }, - { - "name": "stevebauman/purify", - "version": "v6.3.1", - "source": { - "type": "git", - "url": "https://github.com/stevebauman/purify.git", - "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/stevebauman/purify/zipball/3acb5e77904f420ce8aad8fa1c7f394e82daa500", - "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500", - "shasum": "" - }, - "require": { - "ezyang/htmlpurifier": "^4.17", - "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": ">=7.4" - }, - "require-dev": { - "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", - "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Purify": "Stevebauman\\Purify\\Facades\\Purify" - }, - "providers": [ - "Stevebauman\\Purify\\PurifyServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Stevebauman\\Purify\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Steve Bauman", - "email": "steven_bauman@outlook.com" - } - ], - "description": "An HTML Purifier / Sanitizer for Laravel", - "keywords": [ - "Purifier", - "clean", - "cleaner", - "html", - "laravel", - "purification", - "purify" - ], - "support": { - "issues": "https://github.com/stevebauman/purify/issues", - "source": "https://github.com/stevebauman/purify/tree/v6.3.1" - }, - "time": "2025-05-21T16:53:09+00:00" + "time": "2025-01-13T13:04:43+00:00" }, { "name": "symfony/clock", - "version": "v8.0.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", "shasum": "" }, "require": { - "php": ">=8.4", - "psr/clock": "^1.0" + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" }, "provide": { "psr/clock-implementation": "1.0" @@ -5301,7 +4953,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "source": "https://github.com/symfony/clock/tree/v7.3.0" }, "funding": [ { @@ -5312,29 +4964,25 @@ "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-11-12T15:46:48+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/console", - "version": "v7.4.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", - "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", "shasum": "" }, "require": { @@ -5342,7 +4990,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" + "symfony/string": "^7.2" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -5356,16 +5004,16 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -5399,7 +5047,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.3" + "source": "https://github.com/symfony/console/tree/v7.3.2" }, "funding": [ { @@ -5419,24 +5067,24 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2025-07-30T17:13:41+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -5468,7 +5116,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + "source": "https://github.com/symfony/css-selector/tree/v7.3.0" }, "funding": [ { @@ -5479,16 +5127,12 @@ "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-10-30T14:17:19+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5559,33 +5203,32 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", - "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/var-dumper": "^6.4|^7.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -5617,7 +5260,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.0" + "source": "https://github.com/symfony/error-handler/tree/v7.3.2" }, "funding": [ { @@ -5637,28 +5280,28 @@ "type": "tidelift" } ], - "time": "2025-11-05T14:29:59+00:00" + "time": "2025-07-07T08:17:57+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "573f95783a2ec6e38752979db139f09fec033f03" + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/573f95783a2ec6e38752979db139f09fec033f03", - "reference": "573f95783a2ec6e38752979db139f09fec033f03", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/security-http": "<7.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -5667,14 +5310,13 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/error-handler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^7.4|^8.0" + "symfony/stopwatch": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -5702,7 +5344,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" }, "funding": [ { @@ -5713,16 +5355,12 @@ "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-10-30T14:17:19+00:00" + "time": "2025-04-22T09:11:45+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5800,95 +5438,25 @@ ], "time": "2024-09-25T14:21:43+00:00" }, - { - "name": "symfony/filesystem", - "version": "v8.0.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "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": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" - }, - "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-12-01T09:13:36+00:00" - }, { "name": "symfony/finder", - "version": "v7.4.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fffe05569336549b20a1be64250b40516d6e8d06" + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", - "reference": "fffe05569336549b20a1be64250b40516d6e8d06", + "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -5916,7 +5484,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.3" + "source": "https://github.com/symfony/finder/tree/v7.3.2" }, "funding": [ { @@ -5936,26 +5504,27 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:50:43+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", - "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "^1.1" + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" }, "conflict": { "doctrine/dbal": "<3.6", @@ -5964,13 +5533,13 @@ "require-dev": { "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0" + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/clock": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -5998,7 +5567,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.3" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" }, "funding": [ { @@ -6018,29 +5587,29 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:23:49+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "885211d4bed3f857b8c964011923528a55702aa5" + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", - "reference": "885211d4bed3f857b8c964011923528a55702aa5", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^7.3|^8.0", - "symfony/http-foundation": "^7.4|^8.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^7.3", + "symfony/http-foundation": "^7.3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -6050,7 +5619,6 @@ "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", "symfony/doctrine-bridge": "<6.4", - "symfony/flex": "<2.10", "symfony/form": "<6.4", "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", @@ -6068,27 +5636,27 @@ }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^7.1|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.1|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^7.1", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^7.1", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", "twig/twig": "^3.12" }, "type": "library", @@ -6117,7 +5685,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.3" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.2" }, "funding": [ { @@ -6137,20 +5705,20 @@ "type": "tidelift" } ], - "time": "2025-12-31T08:43:57+00:00" + "time": "2025-07-31T10:45:04+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", - "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", + "url": "https://api.github.com/repos/symfony/mailer/zipball/d43e84d9522345f96ad6283d5dfccc8c1cfc299b", + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b", "shasum": "" }, "require": { @@ -6158,8 +5726,8 @@ "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/mime": "^7.2|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/mime": "^7.2", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -6170,10 +5738,10 @@ "symfony/twig-bridge": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/twig-bridge": "^6.4|^7.0|^8.0" + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -6201,7 +5769,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.3" + "source": "https://github.com/symfony/mailer/tree/v7.3.2" }, "funding": [ { @@ -6221,25 +5789,24 @@ "type": "tidelift" } ], - "time": "2025-12-16T08:02:06+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/mime", - "version": "v7.4.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", - "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", + "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -6254,11 +5821,11 @@ "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3" }, "type": "library", "autoload": { @@ -6290,7 +5857,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.0" + "source": "https://github.com/symfony/mime/tree/v7.3.2" }, "funding": [ { @@ -6310,7 +5877,7 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:14:42+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7143,16 +6710,16 @@ }, { "name": "symfony/process", - "version": "v7.4.3", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", - "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", "shasum": "" }, "require": { @@ -7184,7 +6751,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.3" + "source": "https://github.com/symfony/process/tree/v7.3.0" }, "funding": [ { @@ -7195,29 +6762,25 @@ "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-12-19T10:00:43+00:00" + "time": "2025-04-17T09:11:12+00:00" }, { "name": "symfony/routing", - "version": "v7.4.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", - "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", "shasum": "" }, "require": { @@ -7231,11 +6794,11 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7269,7 +6832,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.3" + "source": "https://github.com/symfony/routing/tree/v7.3.2" }, "funding": [ { @@ -7289,20 +6852,20 @@ "type": "tidelift" } ], - "time": "2025-12-19T10:00:43+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { @@ -7356,7 +6919,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -7367,47 +6930,44 @@ "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-15T11:30:57+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { "name": "symfony/string", - "version": "v8.0.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", "shasum": "" }, "require": { - "php": ">=8.4", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", + "symfony/emoji": "^7.1", + "symfony/error-handler": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7446,7 +7006,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.1" + "source": "https://github.com/symfony/string/tree/v7.3.2" }, "funding": [ { @@ -7466,31 +7026,38 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/translation", - "version": "v8.0.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d" + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/60a8f11f0e15c48f2cc47c4da53873bb5b62135d", - "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d", + "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90", "shasum": "" }, "require": { - "php": ">=8.4", - "symfony/polyfill-mbstring": "^1.0", - "symfony/translation-contracts": "^3.6.1" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" }, "conflict": { "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/service-contracts": "<2.5" + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "symfony/translation-implementation": "2.3|3.0" @@ -7498,17 +7065,17 @@ "require-dev": { "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/console": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^7.4|^8.0", + "symfony/routing": "^6.4|^7.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^7.4|^8.0" + "symfony/yaml": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7539,7 +7106,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.3" + "source": "https://github.com/symfony/translation/tree/v7.3.2" }, "funding": [ { @@ -7559,20 +7126,20 @@ "type": "tidelift" } ], - "time": "2025-12-21T10:59:45+00:00" + "time": "2025-07-30T17:31:46+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", "shasum": "" }, "require": { @@ -7621,7 +7188,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" }, "funding": [ { @@ -7632,29 +7199,25 @@ "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-15T13:41:35+00:00" + "time": "2024-09-27T08:32:26+00:00" }, { "name": "symfony/uid", - "version": "v7.4.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", + "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", "shasum": "" }, "require": { @@ -7662,7 +7225,7 @@ "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7699,7 +7262,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.0" + "source": "https://github.com/symfony/uid/tree/v7.3.1" }, "funding": [ { @@ -7710,29 +7273,25 @@ "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-09-25T11:02:55+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92" + "reference": "53205bea27450dc5c65377518b3275e126d45e75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", - "reference": "7e99bebcb3f90d8721890f2963463280848cba92", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", + "reference": "53205bea27450dc5c65377518b3275e126d45e75", "shasum": "" }, "require": { @@ -7744,10 +7303,10 @@ "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", "twig/twig": "^3.12" }, "bin": [ @@ -7786,7 +7345,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" }, "funding": [ { @@ -7806,20 +7365,20 @@ "type": "tidelift" } ], - "time": "2025-12-18T07:04:31+00:00" + "time": "2025-07-29T20:02:46+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.4.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" + "reference": "05b3e90654c097817325d6abd284f7938b05f467" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", - "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/05b3e90654c097817325d6abd284f7938b05f467", + "reference": "05b3e90654c097817325d6abd284f7938b05f467", "shasum": "" }, "require": { @@ -7827,9 +7386,9 @@ "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/property-access": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7867,7 +7426,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.3.2" }, "funding": [ { @@ -7887,32 +7446,32 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:15:23+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "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|^8.0" + "symfony/console": "^6.4|^7.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -7943,7 +7502,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.1" + "source": "https://github.com/symfony/yaml/tree/v7.3.2" }, "funding": [ { @@ -7963,27 +7522,27 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:11:45+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.4.0", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + "reference": "0d72ac1c00084279c1816675284073c5a337c20d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", - "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -8016,32 +7575,32 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" }, - "time": "2025-12-02T11:56:42+00:00" + "time": "2024-12-21T16:25:41+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", + "graham-campbell/result-type": "^1.1.3", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", - "symfony/polyfill-ctype": "^1.26", - "symfony/polyfill-mbstring": "^1.26", - "symfony/polyfill-php80": "^1.26" + "phpoption/phpoption": "^1.9.3", + "symfony/polyfill-ctype": "^1.24", + "symfony/polyfill-mbstring": "^1.24", + "symfony/polyfill-php80": "^1.24" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -8090,7 +7649,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" }, "funding": [ { @@ -8102,7 +7661,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2025-04-30T23:37:27+00:00" }, { "name": "voku/portable-ascii", @@ -8179,23 +7738,81 @@ "time": "2024-11-21T01:49:47+00:00" }, { - "name": "wnx/sidecar-browsershot", - "version": "v2.7.0", + "name": "webmozart/assert", + "version": "1.11.0", "source": { "type": "git", - "url": "https://github.com/stefanzweifel/sidecar-browsershot.git", - "reference": "e42a996c6fab4357919cd5e3f3fab33f019cdd80" + "url": "https://github.com/webmozarts/assert.git", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/stefanzweifel/sidecar-browsershot/zipball/e42a996c6fab4357919cd5e3f3fab33f019cdd80", - "reference": "e42a996c6fab4357919cd5e3f3fab33f019cdd80", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", "shasum": "" }, "require": { - "hammerstone/sidecar": "^0.7", - "illuminate/contracts": "^12.0", - "php": "^8.4", + "ext-ctype": "*", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.11.0" + }, + "time": "2022-06-03T18:03:27+00:00" + }, + { + "name": "wnx/sidecar-browsershot", + "version": "v2.6.0", + "source": { + "type": "git", + "url": "https://github.com/stefanzweifel/sidecar-browsershot.git", + "reference": "20c5a56c34298f7edb7334890e919c0521a7f467" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stefanzweifel/sidecar-browsershot/zipball/20c5a56c34298f7edb7334890e919c0521a7f467", + "reference": "20c5a56c34298f7edb7334890e919c0521a7f467", + "shasum": "" + }, + "require": { + "hammerstone/sidecar": "^0.4 || ^0.5 || ^0.6 || ^0.7", + "illuminate/contracts": "^10.0 || ^11.0 || ^12.0", + "php": "^8.2", "spatie/browsershot": "^4.0 || ^5.0", "spatie/laravel-package-tools": "^1.9.2" }, @@ -8204,15 +7821,15 @@ "laravel/pint": "^1.13", "league/flysystem-aws-s3-v3": "^1.0|^2.0|^3.0", "nunomaduro/collision": "^7.0|^8.0", - "orchestra/testbench": "^10.0", - "pestphp/pest": "^3.0|^4.0", - "pestphp/pest-plugin-laravel": "^3.0|^4.0", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "pestphp/pest-plugin-laravel": "^2.0|^3.0", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", - "phpstan/phpstan-phpunit": "^1.0|^2.0", - "phpunit/phpunit": "^11.0 | ^12.0", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^10 | ^11.0", "spatie/image": "^3.3", - "spatie/pixelmatch-php": "^1.0" + "spatie/pixelmatch-php": "dev-main" }, "type": "library", "extra": { @@ -8254,7 +7871,7 @@ ], "support": { "issues": "https://github.com/stefanzweifel/sidecar-browsershot/issues", - "source": "https://github.com/stefanzweifel/sidecar-browsershot/tree/v2.7.0" + "source": "https://github.com/stefanzweifel/sidecar-browsershot/tree/v2.6.0" }, "funding": [ { @@ -8262,22 +7879,22 @@ "type": "github" } ], - "time": "2025-11-22T08:49:08+00:00" + "time": "2025-05-08T06:40:32+00:00" } ], "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.16.1", + "version": "v7.11.2", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" + "reference": "521aa381c212816d0dc2f04f1532a5831969cb5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/521aa381c212816d0dc2f04f1532a5831969cb5e", + "reference": "521aa381c212816d0dc2f04f1532a5831969cb5e", "shasum": "" }, "require": { @@ -8287,25 +7904,26 @@ "ext-simplexml": "*", "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.2", + "php": "~8.3.0 || ~8.4.0", + "phpunit/php-code-coverage": "^12.3.2", "phpunit/php-file-iterator": "^6", "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.4", + "phpunit/phpunit": "^12.3.5", "sebastian/environment": "^8.0.3", - "symfony/console": "^7.3.4 || ^8.0.0", - "symfony/process": "^7.3.4 || ^8.0.0" + "symfony/console": "^6.4.20 || ^7.3.2", + "symfony/process": "^6.4.20 || ^7.3.0" }, "require-dev": { - "doctrine/coding-standard": "^14.0.0", + "doctrine/coding-standard": "^13.0.1", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan": "^2.1.22", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.11", - "phpstan/phpstan-strict-rules": "^2.0.7", - "symfony/filesystem": "^7.3.2 || ^8.0.0" + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.6", + "squizlabs/php_codesniffer": "^3.13.2", + "symfony/filesystem": "^6.4.13 || ^7.3.2" }, "bin": [ "bin/paratest", @@ -8345,7 +7963,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.11.2" }, "funding": [ { @@ -8357,7 +7975,7 @@ "type": "paypal" } ], - "time": "2026-01-08T07:23:06+00:00" + "time": "2025-08-19T09:24:27+00:00" }, { "name": "doctrine/deprecations", @@ -8756,16 +8374,16 @@ }, { "name": "larastan/larastan", - "version": "v3.8.1", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9" + "reference": "3c223047e374befd1b64959784685d6ecccf66aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/ff3725291bc4c7e6032b5a54776e3e5104c86db9", - "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9", + "url": "https://api.github.com/repos/larastan/larastan/zipball/3c223047e374befd1b64959784685d6ecccf66aa", + "reference": "3c223047e374befd1b64959784685d6ecccf66aa", "shasum": "" }, "require": { @@ -8779,7 +8397,7 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1", "illuminate/support": "^11.44.2 || ^12.4.1", "php": "^8.2", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.1.11" }, "require-dev": { "doctrine/coding-standard": "^13", @@ -8792,8 +8410,7 @@ "phpunit/phpunit": "^10.5.35 || ^11.5.15" }, "suggest": { - "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", - "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" }, "type": "phpstan-extension", "extra": { @@ -8834,7 +8451,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.8.1" + "source": "https://github.com/larastan/larastan/tree/v3.6.1" }, "funding": [ { @@ -8842,40 +8459,39 @@ "type": "github" } ], - "time": "2025-12-11T16:37:35+00:00" + "time": "2025-08-25T07:24:56+00:00" }, { "name": "laravel/boost", - "version": "v1.8.9", + "version": "v1.0.18", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd" + "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/1f2c2d41b5216618170fb6730ec13bf894c5bffd", - "reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd", + "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", - "laravel/mcp": "^0.5.1", - "laravel/prompts": "0.1.25|^0.3.6", - "laravel/roster": "^0.2.9", - "php": "^8.1" + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "laravel/mcp": "^0.1.0", + "laravel/prompts": "^0.1.9|^0.3", + "laravel/roster": "^0.2", + "php": "^8.1|^8.2" }, "require-dev": { - "laravel/pint": "^1.20.0", - "mockery/mockery": "^1.6.12", - "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", - "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", - "phpstan/phpstan": "^2.1.27", - "rector/rector": "^2.1" + "laravel/pint": "^1.14|^1.23", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" }, "type": "library", "extra": { @@ -8897,7 +8513,7 @@ "license": [ "MIT" ], - "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", + "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", "homepage": "https://github.com/laravel/boost", "keywords": [ "ai", @@ -8908,41 +8524,35 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-01-07T18:43:11+00:00" + "time": "2025-08-16T09:10:03+00:00" }, { "name": "laravel/mcp", - "version": "v0.5.2", + "version": "v0.1.1", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c" + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/b9bdd8d6f8b547c8733fe6826b1819341597ba3c", - "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c", + "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", "shasum": "" }, "require": { - "ext-json": "*", - "ext-mbstring": "*", - "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/container": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/http": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/json-schema": "^12.41.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/validation": "^10.49.0|^11.45.3|^12.41.1", - "php": "^8.1" + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2" }, "require-dev": { - "laravel/pint": "^1.20", - "orchestra/testbench": "^8.36|^9.15|^10.8", - "pestphp/pest": "^2.36.0|^3.8.4|^4.1.0", - "phpstan/phpstan": "^2.1.27", - "rector/rector": "^2.2.4" + "laravel/pint": "^1.14", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0" }, "type": "library", "extra": { @@ -8958,6 +8568,8 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", + "Workbench\\App\\": "workbench/app/", + "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -8965,15 +8577,10 @@ "license": [ "MIT" ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Rapidly build MCP servers for your Laravel applications.", + "description": "The easiest way to add MCP servers to your Laravel app.", "homepage": "https://github.com/laravel/mcp", "keywords": [ + "dev", "laravel", "mcp" ], @@ -8981,20 +8588,20 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-12-19T19:32:34+00:00" + "time": "2025-08-16T09:50:43+00:00" }, { "name": "laravel/pail", - "version": "v1.2.4", + "version": "v1.2.3", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "url": "https://api.github.com/repos/laravel/pail/zipball/8cc3d575c1f0e57eeb923f366a37528c50d2385a", + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a", "shasum": "" }, "require": { @@ -9011,9 +8618,9 @@ "require-dev": { "laravel/framework": "^10.24|^11.0|^12.0", "laravel/pint": "^1.13", - "orchestra/testbench-core": "^8.13|^9.17|^10.8", - "pestphp/pest": "^2.20|^3.0|^4.0", - "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "orchestra/testbench-core": "^8.13|^9.0|^10.0", + "pestphp/pest": "^2.20|^3.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0", "phpstan/phpstan": "^1.12.27", "symfony/var-dumper": "^6.3|^7.0" }, @@ -9060,20 +8667,20 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-11-20T16:29:35+00:00" + "time": "2025-06-05T13:55:57+00:00" }, { "name": "laravel/pint", - "version": "v1.27.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", "shasum": "" }, "require": { @@ -9084,19 +8691,22 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.4", - "illuminate/view": "^12.44.0", - "larastan/larastan": "^3.8.1", - "laravel-zero/framework": "^12.0.4", + "friendsofphp/php-cs-fixer": "^3.82.2", + "illuminate/view": "^11.45.1", + "larastan/larastan": "^3.5.0", + "laravel-zero/framework": "^11.45.0", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.4" + "nunomaduro/termwind": "^2.3.1", + "pestphp/pest": "^2.36.0" }, "bin": [ "builds/pint" ], "type": "project", "autoload": { + "files": [ + "overrides/Runner/Parallel/ProcessFactory.php" + ], "psr-4": { "App\\": "app/", "Database\\Seeders\\": "database/seeders/", @@ -9116,7 +8726,6 @@ "description": "An opinionated code formatter for PHP.", "homepage": "https://laravel.com", "keywords": [ - "dev", "format", "formatter", "lint", @@ -9127,20 +8736,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-01-05T16:49:17+00:00" + "time": "2025-07-10T18:09:32+00:00" }, { "name": "laravel/roster", - "version": "v0.2.9", + "version": "v0.2.3", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "82bbd0e2de614906811aebdf16b4305956816fa6" + "reference": "caeed7609b02c00c3f1efec52812d8d87c5d4096" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6", - "reference": "82bbd0e2de614906811aebdf16b4305956816fa6", + "url": "https://api.github.com/repos/laravel/roster/zipball/caeed7609b02c00c3f1efec52812d8d87c5d4096", + "reference": "caeed7609b02c00c3f1efec52812d8d87c5d4096", "shasum": "" }, "require": { @@ -9188,20 +8797,20 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-10-20T09:56:46+00:00" + "time": "2025-08-13T15:00:25+00:00" }, { "name": "laravel/sail", - "version": "v1.52.0", + "version": "v1.44.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" + "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "url": "https://api.github.com/repos/laravel/sail/zipball/a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", + "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", "shasum": "" }, "require": { @@ -9214,7 +8823,7 @@ }, "require-dev": { "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^1.10" }, "bin": [ "bin/sail" @@ -9251,7 +8860,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-01-01T02:46:03+00:00" + "time": "2025-07-04T16:17:06+00:00" }, { "name": "mockery/mockery", @@ -9398,16 +9007,16 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.8.2", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", "shasum": "" }, "require": { @@ -9429,7 +9038,7 @@ "laravel/sanctum": "^4.1.1", "laravel/tinker": "^2.10.1", "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", + "pestphp/pest": "^3.8.2", "sebastian/environment": "^7.2.1 || ^8.0" }, "type": "library", @@ -9493,45 +9102,45 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2025-06-25T02:12:12+00:00" }, { "name": "pestphp/pest", - "version": "v4.3.1", + "version": "v4.0.3", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96" + "reference": "e54e4a0178889209a928f7bee63286149d4eb707" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/bc57a84e77afd4544ff9643a6858f68d05aeab96", - "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96", + "url": "https://api.github.com/repos/pestphp/pest/zipball/e54e4a0178889209a928f7bee63286149d4eb707", + "reference": "e54e4a0178889209a928f7bee63286149d4eb707", "shasum": "" }, "require": { - "brianium/paratest": "^7.16.0", - "nunomaduro/collision": "^8.8.3", - "nunomaduro/termwind": "^2.3.3", + "brianium/paratest": "^7.11.2", + "nunomaduro/collision": "^8.8.2", + "nunomaduro/termwind": "^2.3.1", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", - "pestphp/pest-plugin-profanity": "^4.2.1", + "pestphp/pest-plugin-profanity": "^4.0.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.4", - "symfony/process": "^7.4.3|^8.0.0" + "phpunit/phpunit": "^12.3.6", + "symfony/process": "^7.3.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.4", + "phpunit/phpunit": ">12.3.6", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { "pestphp/pest-dev-tools": "^4.0.0", - "pestphp/pest-plugin-browser": "^4.1.1", - "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.18" + "pestphp/pest-plugin-browser": "^4.0.2", + "pestphp/pest-plugin-type-coverage": "^4.0.2", + "psy/psysh": "^0.12.10" }, "bin": [ "bin/pest" @@ -9597,7 +9206,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.3.1" + "source": "https://github.com/pestphp/pest/tree/v4.0.3" }, "funding": [ { @@ -9609,7 +9218,7 @@ "type": "github" } ], - "time": "2026-01-04T16:29:59+00:00" + "time": "2025-08-24T14:17:23+00:00" }, { "name": "pestphp/pest-plugin", @@ -9972,16 +9581,16 @@ }, { "name": "pestphp/pest-plugin-profanity", - "version": "v4.2.1", + "version": "v4.0.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-profanity.git", - "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27" + "reference": "823d5d8ae07a265c70f5e1a9ce50639543b0bf11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27", - "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/823d5d8ae07a265c70f5e1a9ce50639543b0bf11", + "reference": "823d5d8ae07a265c70f5e1a9ce50639543b0bf11", "shasum": "" }, "require": { @@ -10022,9 +9631,9 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.0.1" }, - "time": "2025-12-08T00:13:17+00:00" + "time": "2025-08-20T12:58:03+00:00" }, { "name": "phar-io/manifest", @@ -10199,16 +9808,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "5.6.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94f8051919d1b0369a6bcc7931d679a511c03fe9", + "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9", "shasum": "" }, "require": { @@ -10218,7 +9827,7 @@ "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1 || ^2" + "webmozart/assert": "^1.9.1" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -10257,22 +9866,22 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.3" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2025-08-01T19:43:32+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", "shasum": "" }, "require": { @@ -10315,22 +9924,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2024-11-09T15:12:26+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.1", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374" + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/16dbf9937da8d4528ceb2145c9c7c0bd29e26374", - "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8", "shasum": "" }, "require": { @@ -10362,17 +9971,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.1" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.2.0" }, - "time": "2026-01-12T11:33:04+00:00" + "time": "2025-07-13T07:04:09+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.33", + "version": "2.1.22", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" + }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f", - "reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", + "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", "shasum": "" }, "require": { @@ -10417,38 +10031,38 @@ "type": "github" } ], - "time": "2025-12-05T10:24:31+00:00" + "time": "2025-08-04T19:17:37+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "086553c5b2e0e1e20293d782d788ab768202b621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/086553c5b2e0e1e20293d782d788ab768202b621", + "reference": "086553c5b2e0e1e20293d782d788ab768202b621", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", + "nikic/php-parser": "^5.4.0", "php": ">=8.3", "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.0", "sebastian/lines-of-code": "^4.0", "sebastian/version": "^6.0", - "theseer/tokenizer": "^2.0.1" + "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^12.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -10457,7 +10071,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5.x-dev" + "dev-main": "12.3.x-dev" } }, "autoload": { @@ -10486,7 +10100,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.3.2" }, "funding": [ { @@ -10506,7 +10120,7 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2025-07-29T06:19:24+00:00" }, { "name": "phpunit/php-file-iterator", @@ -10755,16 +10369,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.4", + "version": "12.3.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a" + "reference": "a2cab3224f687150ac2f3cc13d99b64ba1e1d088" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ba0e923f9d3fc655de22f9547c01d15a41fc93a", - "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a2cab3224f687150ac2f3cc13d99b64ba1e1d088", + "reference": "a2cab3224f687150ac2f3cc13d99b64ba1e1d088", "shasum": "" }, "require": { @@ -10778,17 +10392,17 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.1", + "phpunit/php-code-coverage": "^12.3.2", "phpunit/php-file-iterator": "^6.0.0", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", + "sebastian/cli-parser": "^4.0.0", "sebastian/comparator": "^7.1.3", "sebastian/diff": "^7.0.0", "sebastian/environment": "^8.0.3", - "sebastian/exporter": "^7.0.2", - "sebastian/global-state": "^8.0.2", + "sebastian/exporter": "^7.0.0", + "sebastian/global-state": "^8.0.0", "sebastian/object-enumerator": "^7.0.0", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", @@ -10800,7 +10414,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5-dev" + "dev-main": "12.3-dev" } }, "autoload": { @@ -10832,7 +10446,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.4" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.3.6" }, "funding": [ { @@ -10856,80 +10470,20 @@ "type": "tidelift" } ], - "time": "2025-12-15T06:05:34+00:00" - }, - { - "name": "rector/rector", - "version": "2.3.1", - "source": { - "type": "git", - "url": "https://github.com/rectorphp/rector.git", - "reference": "9afc1bb43571b25629f353c61a9315b5ef31383a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9afc1bb43571b25629f353c61a9315b5ef31383a", - "reference": "9afc1bb43571b25629f353c61a9315b5ef31383a", - "shasum": "" - }, - "require": { - "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.33" - }, - "conflict": { - "rector/rector-doctrine": "*", - "rector/rector-downgrade-php": "*", - "rector/rector-phpunit": "*", - "rector/rector-symfony": "*" - }, - "suggest": { - "ext-dom": "To manipulate phpunit.xml via the custom-rule command" - }, - "bin": [ - "bin/rector" - ], - "type": "library", - "autoload": { - "files": [ - "bootstrap.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Instant Upgrade and Automated Refactoring of any PHP code", - "homepage": "https://getrector.com/", - "keywords": [ - "automation", - "dev", - "migration", - "refactoring" - ], - "support": { - "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.1" - }, - "funding": [ - { - "url": "https://github.com/tomasvotruba", - "type": "github" - } - ], - "time": "2026-01-13T15:13:58+00:00" + "time": "2025-08-20T14:43:23+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "6d584c727d9114bcdc14c86711cd1cad51778e7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/6d584c727d9114bcdc14c86711cd1cad51778e7c", + "reference": "6d584c727d9114bcdc14c86711cd1cad51778e7c", "shasum": "" }, "require": { @@ -10941,7 +10495,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "4.2-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -10965,27 +10519,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", - "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2025-02-07T04:53:50+00:00" }, { "name": "sebastian/comparator", @@ -11282,16 +10824,16 @@ }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "76432aafc58d50691a00d86d0632f1217a47b688" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/76432aafc58d50691a00d86d0632f1217a47b688", + "reference": "76432aafc58d50691a00d86d0632f1217a47b688", "shasum": "" }, "require": { @@ -11348,40 +10890,28 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", - "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2025-02-07T04:56:42+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "570a2aeb26d40f057af686d63c4e99b075fb6cbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/570a2aeb26d40f057af686d63c4e99b075fb6cbc", + "reference": "570a2aeb26d40f057af686d63c4e99b075fb6cbc", "shasum": "" }, "require": { @@ -11422,27 +10952,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", - "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2025-02-07T04:56:59+00:00" }, { "name": "sebastian/lines-of-code", @@ -11928,23 +11446,23 @@ }, { "name": "theseer/tokenizer", - "version": "2.0.1", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", - "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^8.1" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { @@ -11966,7 +11484,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" }, "funding": [ { @@ -11974,69 +11492,7 @@ "type": "github" } ], - "time": "2025-12-08T11:19:18+00:00" - }, - { - "name": "webmozart/assert", - "version": "2.1.2", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^8.2" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-feature/2-0": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" - }, - "time": "2026-01-13T14:02:24+00:00" + "time": "2024-03-03T12:36:25+00:00" } ], "aliases": [], @@ -12047,9 +11503,8 @@ "platform": { "php": "^8.2", "ext-imagick": "*", - "ext-simplexml": "*", "ext-zip": "*" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/config/app.php b/config/app.php index c7cb051..98eaee9 100644 --- a/config/app.php +++ b/config/app.php @@ -130,7 +130,7 @@ return [ 'force_https' => env('FORCE_HTTPS', false), 'puppeteer_docker' => env('PUPPETEER_DOCKER', false), 'puppeteer_mode' => env('PUPPETEER_MODE', 'local'), - 'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', true), + 'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', false), 'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null), 'notifications' => [ @@ -152,5 +152,4 @@ 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/config/services.php b/config/services.php index d97255a..5cb8a74 100644 --- a/config/services.php +++ b/config/services.php @@ -41,8 +41,6 @@ return [ 'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'), 'override_orig_icon' => env('TRMNL_OVERRIDE_ORIG_ICON', false), 'image_url_timeout' => env('TRMNL_IMAGE_URL_TIMEOUT', 30), // 30 seconds; increase on low-powered devices - 'liquid_enabled' => env('TRMNL_LIQUID_ENABLED', false), - 'liquid_path' => env('TRMNL_LIQUID_PATH', '/usr/local/bin/trmnl-liquid-cli'), ], 'webhook' => [ diff --git a/config/trustedproxy.php b/config/trustedproxy.php deleted file mode 100644 index 8557288..0000000 --- a/config/trustedproxy.php +++ /dev/null @@ -1,6 +0,0 @@ - ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)), -]; diff --git a/database/factories/DevicePaletteFactory.php b/database/factories/DevicePaletteFactory.php deleted file mode 100644 index 1d7ed2d..0000000 --- a/database/factories/DevicePaletteFactory.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ -class DevicePaletteFactory extends Factory -{ - protected $model = DevicePalette::class; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'id' => 'test-'.$this->faker->unique()->slug(), - 'name' => $this->faker->words(3, true), - 'grays' => $this->faker->randomElement([2, 4, 16, 256]), - 'colors' => $this->faker->optional()->passthrough([ - '#FF0000', - '#00FF00', - '#0000FF', - '#FFFF00', - '#000000', - '#FFFFFF', - ]), - 'framework_class' => null, - 'source' => 'api', - ]; - } -} 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/2025_08_04_064514_add_oidc_sub_to_users_table.php b/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php index 7ec1374..d8dba38 100644 --- a/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php +++ b/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php @@ -22,7 +22,6 @@ return new class extends Migration public function down(): void { Schema::table('users', function (Blueprint $table) { - $table->dropUnique(['oidc_sub']); $table->dropColumn('oidc_sub'); }); } diff --git a/database/migrations/2025_10_30_144500_add_no_bleed_and_dark_mode_to_plugins_table.php b/database/migrations/2025_10_30_144500_add_no_bleed_and_dark_mode_to_plugins_table.php deleted file mode 100644 index f7329c8..0000000 --- a/database/migrations/2025_10_30_144500_add_no_bleed_and_dark_mode_to_plugins_table.php +++ /dev/null @@ -1,32 +0,0 @@ -boolean('no_bleed')->default(false)->after('configuration_template'); - } - if (! Schema::hasColumn('plugins', 'dark_mode')) { - $table->boolean('dark_mode')->default(false)->after('no_bleed'); - } - }); - } - - public function down(): void - { - Schema::table('plugins', function (Blueprint $table): void { - if (Schema::hasColumn('plugins', 'dark_mode')) { - $table->dropColumn('dark_mode'); - } - if (Schema::hasColumn('plugins', 'no_bleed')) { - $table->dropColumn('no_bleed'); - } - }); - } -}; diff --git a/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php b/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php deleted file mode 100644 index a998420..0000000 --- a/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php +++ /dev/null @@ -1,28 +0,0 @@ -string('preferred_renderer')->nullable()->after('markup_language'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('plugins', function (Blueprint $table) { - $table->dropColumn('preferred_renderer'); - }); - } -}; diff --git a/database/migrations/2025_11_22_084119_create_device_palettes_table.php b/database/migrations/2025_11_22_084119_create_device_palettes_table.php deleted file mode 100644 index 9262dac..0000000 --- a/database/migrations/2025_11_22_084119_create_device_palettes_table.php +++ /dev/null @@ -1,33 +0,0 @@ -id(); - $table->string('name')->unique(); - $table->string('description')->nullable(); - $table->integer('grays'); - $table->json('colors')->nullable(); - $table->string('framework_class')->default(''); - $table->string('source')->default('api'); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('device_palettes'); - } -}; diff --git a/database/migrations/2025_11_22_084208_add_palette_id_to_device_models_table.php b/database/migrations/2025_11_22_084208_add_palette_id_to_device_models_table.php deleted file mode 100644 index 1993fcf..0000000 --- a/database/migrations/2025_11_22_084208_add_palette_id_to_device_models_table.php +++ /dev/null @@ -1,29 +0,0 @@ -foreignId('palette_id')->nullable()->after('source')->constrained('device_palettes')->onDelete('set null'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('device_models', function (Blueprint $table) { - $table->dropForeign(['palette_id']); - $table->dropColumn('palette_id'); - }); - } -}; diff --git a/database/migrations/2025_11_22_084211_add_palette_id_to_devices_table.php b/database/migrations/2025_11_22_084211_add_palette_id_to_devices_table.php deleted file mode 100644 index 3a47afe..0000000 --- a/database/migrations/2025_11_22_084211_add_palette_id_to_devices_table.php +++ /dev/null @@ -1,29 +0,0 @@ -foreignId('palette_id')->nullable()->constrained('device_palettes')->onDelete('set null'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('devices', function (Blueprint $table) { - $table->dropForeign(['palette_id']); - $table->dropColumn('palette_id'); - }); - } -}; diff --git a/database/migrations/2025_11_22_084425_seed_palettes_and_relationships.php b/database/migrations/2025_11_22_084425_seed_palettes_and_relationships.php deleted file mode 100644 index c198d81..0000000 --- a/database/migrations/2025_11_22_084425_seed_palettes_and_relationships.php +++ /dev/null @@ -1,124 +0,0 @@ - 'bw', - 'description' => 'Black & White', - 'grays' => 2, - 'colors' => null, - 'framework_class' => 'screen--1bit', - 'source' => 'api', - ], - [ - 'name' => 'gray-4', - 'description' => '4 Grays', - 'grays' => 4, - 'colors' => null, - 'framework_class' => 'screen--2bit', - 'source' => 'api', - ], - [ - 'name' => 'gray-16', - 'description' => '16 Grays', - 'grays' => 16, - 'colors' => null, - 'framework_class' => 'screen--4bit', - 'source' => 'api', - ], - [ - 'name' => 'gray-256', - 'description' => '256 Grays', - 'grays' => 256, - 'colors' => null, - 'framework_class' => 'screen--4bit', - 'source' => 'api', - ], - [ - 'name' => 'color-6a', - 'description' => '6 Colors', - 'grays' => 2, - 'colors' => json_encode(['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#000000', '#FFFFFF']), - 'framework_class' => '', - 'source' => 'api', - ], - [ - 'name' => 'color-7a', - 'description' => '7 Colors', - 'grays' => 2, - 'colors' => json_encode(['#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FFA500']), - 'framework_class' => '', - 'source' => 'api', - ], - ]; - - $now = now(); - $paletteIdMap = []; - - foreach ($palettes as $paletteData) { - $paletteName = $paletteData['name']; - $paletteData['created_at'] = $now; - $paletteData['updated_at'] = $now; - - DB::table('device_palettes')->updateOrInsert( - ['name' => $paletteName], - $paletteData - ); - - // Get the ID of the palette (either newly created or existing) - $paletteRecord = DB::table('device_palettes')->where('name', $paletteName)->first(); - $paletteIdMap[$paletteName] = $paletteRecord->id; - } - - // Set default palette_id on DeviceModel based on first palette_ids entry - $models = [ - ['name' => 'og_png', 'palette_name' => 'bw'], - ['name' => 'og_plus', 'palette_name' => 'gray-4'], - ['name' => 'amazon_kindle_2024', 'palette_name' => 'gray-256'], - ['name' => 'amazon_kindle_paperwhite_6th_gen', 'palette_name' => 'gray-256'], - ['name' => 'amazon_kindle_paperwhite_7th_gen', 'palette_name' => 'gray-256'], - ['name' => 'inkplate_10', 'palette_name' => 'gray-4'], - ['name' => 'amazon_kindle_7', 'palette_name' => 'gray-256'], - ['name' => 'inky_impression_7_3', 'palette_name' => 'color-7a'], - ['name' => 'kobo_libra_2', 'palette_name' => 'gray-16'], - ['name' => 'amazon_kindle_oasis_2', 'palette_name' => 'gray-256'], - ['name' => 'kobo_aura_one', 'palette_name' => 'gray-16'], - ['name' => 'kobo_aura_hd', 'palette_name' => 'gray-16'], - ['name' => 'inky_impression_13_3', 'palette_name' => 'color-6a'], - ['name' => 'm5_paper_s3', 'palette_name' => 'gray-16'], - ['name' => 'amazon_kindle_scribe', 'palette_name' => 'gray-256'], - ['name' => 'seeed_e1001', 'palette_name' => 'gray-4'], - ['name' => 'seeed_e1002', 'palette_name' => 'gray-4'], - ['name' => 'waveshare_4_26', 'palette_name' => 'gray-4'], - ['name' => 'waveshare_7_5_bw', 'palette_name' => 'bw'], - ]; - - foreach ($models as $modelData) { - $deviceModel = DeviceModel::where('name', $modelData['name'])->first(); - if ($deviceModel && ! $deviceModel->palette_id && isset($paletteIdMap[$modelData['palette_name']])) { - $deviceModel->update(['palette_id' => $paletteIdMap[$modelData['palette_name']]]); - } - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - // Remove palette_id from device models but keep palettes - DeviceModel::query()->update(['palette_id' => null]); - } -}; diff --git a/database/migrations/2025_12_02_154228_add_timezone_to_users_table.php b/database/migrations/2025_12_02_154228_add_timezone_to_users_table.php deleted file mode 100644 index 8a92627..0000000 --- a/database/migrations/2025_12_02_154228_add_timezone_to_users_table.php +++ /dev/null @@ -1,28 +0,0 @@ -string('timezone')->nullable()->after('oidc_sub'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('users', function (Blueprint $table) { - $table->dropColumn('timezone'); - }); - } -}; 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/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php b/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php deleted file mode 100644 index d230657..0000000 --- a/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php +++ /dev/null @@ -1,33 +0,0 @@ -string('kind')->nullable()->index(); - }); - - // Set existing og_png and og_plus to kind "trmnl" - DeviceModel::whereIn('name', ['og_png', 'og_plus'])->update(['kind' => 'trmnl']); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('device_models', function (Blueprint $table) { - $table->dropIndex(['kind']); - $table->dropColumn('kind'); - }); - } -}; diff --git a/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php b/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php deleted file mode 100644 index 3b9b1b7..0000000 --- a/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php +++ /dev/null @@ -1,58 +0,0 @@ -selectRaw('user_id, trmnlp_id, COUNT(*) as duplicate_count') - ->whereNotNull('trmnlp_id') - ->groupBy('user_id', 'trmnlp_id') - ->havingRaw('COUNT(*) > ?', [1]) - ->get(); - - // For each duplicate combination, keep the first one (by id) and set others to null - foreach ($duplicates as $duplicate) { - $plugins = Plugin::query() - ->where('user_id', $duplicate->user_id) - ->where('trmnlp_id', $duplicate->trmnlp_id) - ->orderBy('id') - ->get(); - - // Keep the first one, set the rest to null - $keepFirst = true; - foreach ($plugins as $plugin) { - if ($keepFirst) { - $keepFirst = false; - - continue; - } - - $plugin->update(['trmnlp_id' => null]); - } - } - - Schema::table('plugins', function (Blueprint $table) { - $table->unique(['user_id', 'trmnlp_id']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('plugins', function (Blueprint $table) { - $table->dropUnique(['user_id', 'trmnlp_id']); - }); - } -}; diff --git a/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php b/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php deleted file mode 100644 index 0a527d7..0000000 --- a/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('alias')->default(false); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('plugins', function (Blueprint $table) { - $table->dropColumn('alias'); - }); - } -}; diff --git a/database/seeders/ExampleRecipesSeeder.php b/database/seeders/ExampleRecipesSeeder.php index 890eed9..9d8e9bb 100644 --- a/database/seeders/ExampleRecipesSeeder.php +++ b/database/seeders/ExampleRecipesSeeder.php @@ -13,8 +13,8 @@ class ExampleRecipesSeeder extends Seeder public function run($user_id = 1): void { Plugin::updateOrCreate( - ['uuid' => '9e46c6cf-358c-4bfe-8998-436b3a207fec'], [ + 'uuid' => '9e46c6cf-358c-4bfe-8998-436b3a207fec', 'name' => 'ÖBB Departures', 'user_id' => $user_id, 'data_payload' => null, @@ -32,8 +32,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '3b046eda-34e9-4232-b935-c33b989a284b'], [ + 'uuid' => '3b046eda-34e9-4232-b935-c33b989a284b', 'name' => 'Weather', 'user_id' => $user_id, 'data_payload' => null, @@ -51,8 +51,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54'], [ + 'uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54', 'name' => 'Zen Quotes', 'user_id' => $user_id, 'data_payload' => null, @@ -70,8 +70,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f'], [ + 'uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f', 'name' => 'This Day in History', 'user_id' => $user_id, 'data_payload' => null, @@ -89,8 +89,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d'], [ + 'uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d', 'name' => 'Home Assistant', 'user_id' => $user_id, 'data_payload' => null, @@ -108,8 +108,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80'], [ + 'uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80', 'name' => 'Sunrise/Sunset', 'user_id' => $user_id, 'data_payload' => null, @@ -127,8 +127,8 @@ class ExampleRecipesSeeder extends Seeder ); Plugin::updateOrCreate( - ['uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe'], [ + 'uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe', 'name' => 'Pollen Forecast', 'user_id' => $user_id, 'data_payload' => null, @@ -144,42 +144,5 @@ class ExampleRecipesSeeder extends Seeder 'flux_icon_name' => 'flower', ] ); - - Plugin::updateOrCreate( - ['uuid' => '1d98bca4-837d-4b01-b1a1-e3b6e56eca90'], - [ - 'name' => 'Holidays (iCal)', - 'user_id' => $user_id, - 'data_payload' => null, - 'data_stale_minutes' => 720, - 'data_strategy' => 'polling', - 'configuration_template' => [ - 'custom_fields' => [ - [ - 'keyname' => 'calendar', - 'field_type' => 'select', - 'name' => 'Public Holidays Calendar', - 'options' => [ - ['USA' => 'usa'], - ['Austria' => 'austria'], - ['Australia' => 'australia'], - ['Canada' => 'canada'], - ['Germany' => 'germany'], - ['UK' => 'united-kingdom'], - ], - ], - ], - ], - 'configuration' => ['calendar' => 'usa'], - 'polling_url' => 'https://www.officeholidays.com/ics-clean/{{calendar}}', - 'polling_verb' => 'get', - 'polling_header' => null, - 'render_markup' => null, - 'render_markup_view' => 'recipes.holidays-ical', - 'detail_view_route' => null, - 'icon_url' => null, - 'flux_icon_name' => 'calendar', - ] - ); } } diff --git a/package-lock.json b/package-lock.json index e722432..306c4f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,31 +1,18 @@ { - "name": "laravel", + "name": "byos_laravel", "lockfileVersion": 3, "requires": true, "packages": { "": { "dependencies": { - "@codemirror/commands": "^6.9.0", - "@codemirror/lang-css": "^6.3.1", - "@codemirror/lang-html": "^6.4.11", - "@codemirror/lang-javascript": "^6.2.4", - "@codemirror/lang-json": "^6.0.2", - "@codemirror/lang-liquid": "^6.3.0", - "@codemirror/language": "^6.11.3", - "@codemirror/search": "^6.5.11", - "@codemirror/state": "^6.5.2", - "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.38.5", - "@fsegurai/codemirror-theme-github-light": "^6.2.2", - "@tailwindcss/vite": "^4.1.11", + "@tailwindcss/vite": "^4.0.7", "autoprefixer": "^10.4.20", "axios": "^1.8.2", - "codemirror": "^6.0.2", "concurrently": "^9.0.1", - "laravel-vite-plugin": "^2.0", - "puppeteer": "24.30.0", + "laravel-vite-plugin": "^1.0", + "puppeteer": "^24.3.0", "tailwindcss": "^4.0.7", - "vite": "^7.0.4" + "vite": "^6.3" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", @@ -48,182 +35,18 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@codemirror/autocomplete": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", - "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@codemirror/commands": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz", - "integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.4.0", - "@codemirror/view": "^6.27.0", - "@lezer/common": "^1.1.0" - } - }, - "node_modules/@codemirror/lang-css": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", - "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.0.2", - "@lezer/css": "^1.1.7" - } - }, - "node_modules/@codemirror/lang-html": { - "version": "6.4.11", - "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", - "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/lang-css": "^6.0.0", - "@codemirror/lang-javascript": "^6.0.0", - "@codemirror/language": "^6.4.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0", - "@lezer/css": "^1.1.0", - "@lezer/html": "^1.3.12" - } - }, - "node_modules/@codemirror/lang-javascript": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", - "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.6.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0", - "@lezer/javascript": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-json": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", - "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@lezer/json": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-liquid": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.0.tgz", - "integrity": "sha512-fY1YsUExcieXRTsCiwX/bQ9+PbCTA/Fumv7C7mTUZHoFkibfESnaXwpr2aKH6zZVwysEunsHHkaIpM/pl3xETQ==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/lang-html": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.1" - } - }, - "node_modules/@codemirror/language": { - "version": "6.11.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", - "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0", - "style-mod": "^4.0.0" - } - }, - "node_modules/@codemirror/lint": { - "version": "6.9.2", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz", - "integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.35.0", - "crelt": "^1.0.5" - } - }, - "node_modules/@codemirror/search": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", - "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "crelt": "^1.0.5" - } - }, - "node_modules/@codemirror/state": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", - "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", - "license": "MIT", - "dependencies": { - "@marijn/find-cluster-break": "^1.0.0" - } - }, - "node_modules/@codemirror/theme-one-dark": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", - "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/highlight": "^1.0.0" - } - }, - "node_modules/@codemirror/view": { - "version": "6.38.8", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", - "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.5.0", - "crelt": "^1.0.6", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" - } - }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", "cpu": [ "ppc64" ], @@ -237,9 +60,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", "cpu": [ "arm" ], @@ -253,9 +76,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", "cpu": [ "arm64" ], @@ -269,9 +92,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", "cpu": [ "x64" ], @@ -285,9 +108,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", "cpu": [ "arm64" ], @@ -301,9 +124,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", "cpu": [ "x64" ], @@ -317,9 +140,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", "cpu": [ "arm64" ], @@ -333,9 +156,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", "cpu": [ "x64" ], @@ -349,9 +172,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", "cpu": [ "arm" ], @@ -365,9 +188,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", "cpu": [ "arm64" ], @@ -381,9 +204,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", "cpu": [ "ia32" ], @@ -397,9 +220,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", "cpu": [ "loong64" ], @@ -413,9 +236,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", "cpu": [ "mips64el" ], @@ -429,9 +252,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", "cpu": [ "ppc64" ], @@ -445,9 +268,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", "cpu": [ "riscv64" ], @@ -461,9 +284,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", "cpu": [ "s390x" ], @@ -477,9 +300,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", "cpu": [ "x64" ], @@ -493,9 +316,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", "cpu": [ "arm64" ], @@ -509,9 +332,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", "cpu": [ "x64" ], @@ -525,9 +348,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", "cpu": [ "arm64" ], @@ -541,9 +364,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", "cpu": [ "x64" ], @@ -557,9 +380,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", "cpu": [ "arm64" ], @@ -573,9 +396,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", "cpu": [ "x64" ], @@ -589,9 +412,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", "cpu": [ "arm64" ], @@ -605,9 +428,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "cpu": [ "ia32" ], @@ -621,9 +444,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", "cpu": [ "x64" ], @@ -636,16 +459,16 @@ "node": ">=18" } }, - "node_modules/@fsegurai/codemirror-theme-github-light": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/@fsegurai/codemirror-theme-github-light/-/codemirror-theme-github-light-6.2.3.tgz", - "integrity": "sha512-vbwyznBoTrLQdWvQ6/vjIpoDojd7VIMK+sQnMXkKOjXbm5cGul6A3mqM2RSt9Z5NhIRikmxKkApflvWOJrDuWA==", - "license": "MIT", - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/highlight": "^1.0.0" + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -684,101 +507,27 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@lezer/common": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", - "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==", - "license": "MIT" - }, - "node_modules/@lezer/css": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", - "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.0" - } - }, - "node_modules/@lezer/highlight": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", - "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.3.0" - } - }, - "node_modules/@lezer/html": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz", - "integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/javascript": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", - "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.1.3", - "@lezer/lr": "^1.3.0" - } - }, - "node_modules/@lezer/json": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", - "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/lr": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.4.tgz", - "integrity": "sha512-LHL17Mq0OcFXm1pGQssuGTQFPPdxARjKM8f7GA5+sGtHi0K3R84YaSbmche0+RKWHnCsx9asEe5OWOI4FHfe4A==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@marijn/find-cluster-break": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", - "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", - "license": "MIT" - }, "node_modules/@puppeteer/browsers": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", - "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.7.tgz", + "integrity": "sha512-wHWLkQWBjHtajZeqCB74nsa/X70KheyOhySYBRmVQDJiNj0zjZR/naPCvdWjMhcG1LmjaMV/9WtTo5mpe8qWLw==", "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.3", + "debug": "^4.4.1", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", - "semver": "^7.7.3", - "tar-fs": "^3.1.1", + "semver": "^7.7.2", + "tar-fs": "^3.1.0", "yargs": "^17.7.2" }, "bin": { @@ -789,9 +538,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.49.0.tgz", + "integrity": "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA==", "cpu": [ "arm" ], @@ -802,9 +551,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.49.0.tgz", + "integrity": "sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w==", "cpu": [ "arm64" ], @@ -815,9 +564,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.49.0.tgz", + "integrity": "sha512-99kMMSMQT7got6iYX3yyIiJfFndpojBmkHfTc1rIje8VbjhmqBXE+nb7ZZP3A5skLyujvT0eIUCUsxAe6NjWbw==", "cpu": [ "arm64" ], @@ -828,9 +577,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.49.0.tgz", + "integrity": "sha512-y8cXoD3wdWUDpjOLMKLx6l+NFz3NlkWKcBCBfttUn+VGSfgsQ5o/yDUGtzE9HvsodkP0+16N0P4Ty1VuhtRUGg==", "cpu": [ "x64" ], @@ -841,9 +590,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.49.0.tgz", + "integrity": "sha512-3mY5Pr7qv4GS4ZvWoSP8zha8YoiqrU+e0ViPvB549jvliBbdNLrg2ywPGkgLC3cmvN8ya3za+Q2xVyT6z+vZqA==", "cpu": [ "arm64" ], @@ -854,9 +603,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.49.0.tgz", + "integrity": "sha512-C9KzzOAQU5gU4kG8DTk+tjdKjpWhVWd5uVkinCwwFub2m7cDYLOdtXoMrExfeBmeRy9kBQMkiyJ+HULyF1yj9w==", "cpu": [ "x64" ], @@ -867,9 +616,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.49.0.tgz", + "integrity": "sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==", "cpu": [ "arm" ], @@ -880,9 +629,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.49.0.tgz", + "integrity": "sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==", "cpu": [ "arm" ], @@ -893,9 +642,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.49.0.tgz", + "integrity": "sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==", "cpu": [ "arm64" ], @@ -906,9 +655,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.49.0.tgz", + "integrity": "sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==", "cpu": [ "arm64" ], @@ -918,10 +667,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.49.0.tgz", + "integrity": "sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==", "cpu": [ "loong64" ], @@ -932,9 +681,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.49.0.tgz", + "integrity": "sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==", "cpu": [ "ppc64" ], @@ -945,9 +694,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.49.0.tgz", + "integrity": "sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==", "cpu": [ "riscv64" ], @@ -958,9 +707,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.49.0.tgz", + "integrity": "sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==", "cpu": [ "riscv64" ], @@ -971,9 +720,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.49.0.tgz", + "integrity": "sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==", "cpu": [ "s390x" ], @@ -997,9 +746,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.49.0.tgz", + "integrity": "sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==", "cpu": [ "x64" ], @@ -1009,23 +758,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.49.0.tgz", + "integrity": "sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==", "cpu": [ "arm64" ], @@ -1036,9 +772,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.49.0.tgz", + "integrity": "sha512-gq5aW/SyNpjp71AAzroH37DtINDcX1Qw2iv9Chyz49ZgdOP3NV8QCyKZUrGsYX9Yyggj5soFiRCgsL3HwD8TdA==", "cpu": [ "ia32" ], @@ -1048,23 +784,10 @@ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.49.0.tgz", + "integrity": "sha512-gEtqFbzmZLFk2xKh7g0Rlo8xzho8KrEFEkzvHbfUGkrgXOpZ4XagQ6n+wIZFNh1nTb8UD16J4nFSFKXYgnbdBg==", "cpu": [ "x64" ], @@ -1075,47 +798,52 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" + "tailwindcss": "4.1.12" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", + "hasInstallScript": true, "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", + "integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", "cpu": [ "arm64" ], @@ -1129,9 +857,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", + "integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", "cpu": [ "arm64" ], @@ -1145,9 +873,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", "cpu": [ "x64" ], @@ -1161,9 +889,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", + "integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", "cpu": [ "x64" ], @@ -1177,9 +905,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", + "integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", "cpu": [ "arm" ], @@ -1193,9 +921,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", + "integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", "cpu": [ "arm64" ], @@ -1209,9 +937,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", + "integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", "cpu": [ "arm64" ], @@ -1225,9 +953,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", + "integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", "cpu": [ "x64" ], @@ -1241,9 +969,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", + "integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", "cpu": [ "x64" ], @@ -1257,9 +985,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", + "integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1274,21 +1002,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", + "integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", "cpu": [ "arm64" ], @@ -1302,9 +1030,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", + "integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", "cpu": [ "x64" ], @@ -1318,14 +1046,14 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", - "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz", + "integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "tailwindcss": "4.1.17" + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", + "tailwindcss": "4.1.12" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -1344,13 +1072,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", "license": "MIT", "optional": true, "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.10.0" } }, "node_modules/@types/yauzl": { @@ -1421,9 +1149,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", - "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", "funding": [ { "type": "opencollective", @@ -1440,9 +1168,9 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.27.0", - "caniuse-lite": "^1.0.30001754", - "fraction.js": "^5.3.4", + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -1458,9 +1186,9 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -1469,45 +1197,28 @@ } }, "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" }, "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.1.tgz", + "integrity": "sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==", "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } + "optional": true }, "node_modules/bare-fs": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", - "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.2.1.tgz", + "integrity": "sha512-mELROzV0IhqilFgsl1gyp48pnZsaV9xhQapHLDsvn4d4ZTfbFhcghQezl7FTEDNBcGqLUnNI3lUlm6ecrLWdFA==", "license": "Apache-2.0", "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" + "bare-stream": "^2.6.4" }, "engines": { "bare": ">=1.16.0" @@ -1563,25 +1274,6 @@ } } }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.3.tgz", - "integrity": "sha512-8QdH6czo+G7uBsNo0GiUfouPN1lRzKdJTGnKXwe12gkFbnnOUaUKGN55dMkfy+mnxmvjwl9zcI4VncczcVXDhA==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, "node_modules/basic-ftp": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", @@ -1592,9 +1284,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.25.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", + "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", "funding": [ { "type": "opencollective", @@ -1611,11 +1303,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "caniuse-lite": "^1.0.30001735", + "electron-to-chromium": "^1.5.204", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -1656,9 +1347,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001759", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", - "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "version": "1.0.30001737", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", + "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", "funding": [ { "type": "opencollective", @@ -1703,10 +1394,19 @@ "node": ">=8" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/chromium-bidi": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-11.0.0.tgz", - "integrity": "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-8.0.0.tgz", + "integrity": "sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==", "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", @@ -1730,21 +1430,6 @@ "node": ">=12" } }, - "node_modules/codemirror": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", - "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/commands": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/search": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1825,12 +1510,6 @@ } } }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" - }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -1841,9 +1520,9 @@ } }, "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1881,18 +1560,18 @@ } }, "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "license": "Apache-2.0", "engines": { "node": ">=8" } }, "node_modules/devtools-protocol": { - "version": "0.0.1521046", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", - "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", + "version": "0.0.1475386", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz", + "integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==", "license": "BSD-3-Clause" }, "node_modules/dunder-proto": { @@ -1910,9 +1589,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.266", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", - "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "version": "1.5.209", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.209.tgz", + "integrity": "sha512-Xoz0uMrim9ZETCQt8UgM5FxQF9+imA7PBpokoGcZloA1uw2LeHzTlip5cb5KOAsXZLjh/moN2vReN3ZjJmjI9A==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -1953,9 +1632,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -2007,9 +1686,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -2019,32 +1698,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" } }, "node_modules/escalade": { @@ -2108,15 +1787,6 @@ "node": ">=0.10.0" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -2190,9 +1860,9 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2206,15 +1876,15 @@ } }, "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "github", + "type": "patreon", "url": "https://github.com/sponsors/rawify" } }, @@ -2425,9 +2095,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "license": "MIT", "engines": { "node": ">= 12" @@ -2449,9 +2119,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -2464,9 +2134,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2482,9 +2152,9 @@ "license": "MIT" }, "node_modules/laravel-vite-plugin": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz", - "integrity": "sha512-zQuvzWfUKQu9oNVi1o0RZAJCwhGsdhx4NEOyrVQwJHaWDseGP9tl7XUPLY2T8Cj6+IrZ6lmyxlR1KC8unf3RLA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz", + "integrity": "sha512-P5qyG56YbYxM8OuYmK2OkhcKe0AksNVJUjq9LUZ5tOekU9fBn9LujYyctI4t9XoLjuMvHJXXpCoPntY1oKltuA==", "license": "MIT", "dependencies": { "picocolors": "^1.0.0", @@ -2494,16 +2164,16 @@ "clean-orphaned-assets": "bin/clean.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "peerDependencies": { - "vite": "^7.0.0" + "vite": "^5.0.0 || ^6.0.0" } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -2516,43 +2186,22 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", "cpu": [ "arm64" ], @@ -2570,9 +2219,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", "cpu": [ "x64" ], @@ -2590,9 +2239,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", "cpu": [ "x64" ], @@ -2610,9 +2259,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", "cpu": [ "arm" ], @@ -2630,9 +2279,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", "cpu": [ "arm64" ], @@ -2650,9 +2299,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", "cpu": [ "arm64" ], @@ -2670,9 +2319,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", "cpu": [ "x64" ], @@ -2690,9 +2339,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", "cpu": [ "x64" ], @@ -2710,9 +2359,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", "cpu": [ "arm64" ], @@ -2730,9 +2379,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", "cpu": [ "x64" ], @@ -2765,9 +2414,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "0.30.18", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", + "integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -2803,12 +2452,48 @@ "node": ">= 0.6" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2843,9 +2528,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "license": "MIT" }, "node_modules/normalize-range": { @@ -3031,17 +2716,17 @@ } }, "node_modules/puppeteer": { - "version": "24.30.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.30.0.tgz", - "integrity": "sha512-A5OtCi9WpiXBQgJ2vQiZHSyrAzQmO/WDsvghqlN4kgw21PhxA5knHUaUQq/N3EMt8CcvSS0RM+kmYLJmedR3TQ==", + "version": "24.17.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.17.0.tgz", + "integrity": "sha512-CGrmJ8WgilK3nyE73k+pbxHggETPpEvL6AQ9H5JSK1RgZRGMQVJ+iO3MocGm9yBQXQJ9U5xijyLvkYXFeb0/+g==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.13", - "chromium-bidi": "11.0.0", + "@puppeteer/browsers": "2.10.7", + "chromium-bidi": "8.0.0", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1521046", - "puppeteer-core": "24.30.0", + "devtools-protocol": "0.0.1475386", + "puppeteer-core": "24.17.0", "typed-query-selector": "^2.12.0" }, "bin": { @@ -3052,17 +2737,16 @@ } }, "node_modules/puppeteer-core": { - "version": "24.30.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.30.0.tgz", - "integrity": "sha512-2S3Smy0t0W4wJnNvDe7W0bE7wDmZjfZ3ljfMgJd6hn2Hq/f0jgN+x9PULZo2U3fu5UUIJ+JP8cNUGllu8P91Pg==", + "version": "24.17.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.17.0.tgz", + "integrity": "sha512-RYOBKFiF+3RdwIZTEacqNpD567gaFcBAOKTT7742FdB1icXudrPI7BlZbYTYWK2wgGQUXt9Zi1Yn+D5PmCs4CA==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.13", - "chromium-bidi": "11.0.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1521046", + "@puppeteer/browsers": "2.10.7", + "chromium-bidi": "8.0.0", + "debug": "^4.4.1", + "devtools-protocol": "0.0.1475386", "typed-query-selector": "^2.12.0", - "webdriver-bidi-protocol": "0.3.8", "ws": "^8.18.3" }, "engines": { @@ -3088,9 +2772,9 @@ } }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz", + "integrity": "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -3103,35 +2787,33 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.49.0", + "@rollup/rollup-android-arm64": "4.49.0", + "@rollup/rollup-darwin-arm64": "4.49.0", + "@rollup/rollup-darwin-x64": "4.49.0", + "@rollup/rollup-freebsd-arm64": "4.49.0", + "@rollup/rollup-freebsd-x64": "4.49.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", + "@rollup/rollup-linux-arm-musleabihf": "4.49.0", + "@rollup/rollup-linux-arm64-gnu": "4.49.0", + "@rollup/rollup-linux-arm64-musl": "4.49.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", + "@rollup/rollup-linux-ppc64-gnu": "4.49.0", + "@rollup/rollup-linux-riscv64-gnu": "4.49.0", + "@rollup/rollup-linux-riscv64-musl": "4.49.0", + "@rollup/rollup-linux-s390x-gnu": "4.49.0", + "@rollup/rollup-linux-x64-gnu": "4.49.0", + "@rollup/rollup-linux-x64-musl": "4.49.0", + "@rollup/rollup-win32-arm64-msvc": "4.49.0", + "@rollup/rollup-win32-ia32-msvc": "4.49.0", + "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "~2.3.2" } }, "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.49.0.tgz", + "integrity": "sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==", "cpu": [ "x64" ], @@ -3151,9 +2833,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3232,14 +2914,16 @@ } }, "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", + "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", "license": "MIT", "dependencies": { - "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" } }, "node_modules/string-width": { @@ -3268,12 +2952,6 @@ "node": ">=8" } }, - "node_modules/style-mod": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", - "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", - "license": "MIT" - }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -3290,15 +2968,15 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "license": "MIT", "engines": { "node": ">=6" @@ -3308,10 +2986,27 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", + "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -3343,13 +3038,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "fdir": "^6.4.4", + "picomatch": "^4.0.2" }, "engines": { "node": ">=12.0.0" @@ -3380,16 +3075,16 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "license": "MIT", "optional": true }, "node_modules/update-browserslist-db": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", - "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "funding": [ { "type": "opencollective", @@ -3417,23 +3112,23 @@ } }, "node_modules/vite": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", - "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -3442,14 +3137,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", - "less": "^4.0.0", + "less": "*", "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -3512,18 +3207,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" - }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.8.tgz", - "integrity": "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==", - "license": "Apache-2.0" - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3577,6 +3260,15 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 7262ad1..5073158 100644 --- a/package.json +++ b/package.json @@ -6,27 +6,14 @@ "dev": "vite" }, "dependencies": { - "@codemirror/commands": "^6.9.0", - "@codemirror/lang-css": "^6.3.1", - "@codemirror/lang-html": "^6.4.11", - "@codemirror/lang-javascript": "^6.2.4", - "@codemirror/lang-json": "^6.0.2", - "@codemirror/lang-liquid": "^6.3.0", - "@codemirror/language": "^6.11.3", - "@codemirror/search": "^6.5.11", - "@codemirror/state": "^6.5.2", - "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.38.5", - "@fsegurai/codemirror-theme-github-light": "^6.2.2", - "@tailwindcss/vite": "^4.1.11", + "@tailwindcss/vite": "^4.0.7", "autoprefixer": "^10.4.20", "axios": "^1.8.2", - "codemirror": "^6.0.2", "concurrently": "^9.0.1", - "laravel-vite-plugin": "^2.0", - "puppeteer": "24.30.0", + "laravel-vite-plugin": "^1.0", + "puppeteer": "^24.3.0", "tailwindcss": "^4.0.7", - "vite": "^7.0.4" + "vite": "^6.3" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", diff --git a/public/mirror/assets/apple-touch-icon-120x120.png b/public/mirror/assets/apple-touch-icon-120x120.png deleted file mode 100644 index 5e51318..0000000 Binary files a/public/mirror/assets/apple-touch-icon-120x120.png and /dev/null differ diff --git a/public/mirror/assets/apple-touch-icon-152x152.png b/public/mirror/assets/apple-touch-icon-152x152.png deleted file mode 100644 index 9f8d9e3..0000000 Binary files a/public/mirror/assets/apple-touch-icon-152x152.png and /dev/null differ diff --git a/public/mirror/assets/apple-touch-icon-167x167.png b/public/mirror/assets/apple-touch-icon-167x167.png deleted file mode 100644 index 79d1211..0000000 Binary files a/public/mirror/assets/apple-touch-icon-167x167.png and /dev/null differ diff --git a/public/mirror/assets/apple-touch-icon-180x180.png b/public/mirror/assets/apple-touch-icon-180x180.png deleted file mode 100644 index 0499ff4..0000000 Binary files a/public/mirror/assets/apple-touch-icon-180x180.png and /dev/null differ diff --git a/public/mirror/assets/apple-touch-icon-76x76.png b/public/mirror/assets/apple-touch-icon-76x76.png deleted file mode 100644 index df3943a..0000000 Binary files a/public/mirror/assets/apple-touch-icon-76x76.png and /dev/null differ diff --git a/public/mirror/assets/favicon-16x16.png b/public/mirror/assets/favicon-16x16.png deleted file mode 100644 index b36f23b..0000000 Binary files a/public/mirror/assets/favicon-16x16.png and /dev/null differ diff --git a/public/mirror/assets/favicon-32x32.png b/public/mirror/assets/favicon-32x32.png deleted file mode 100644 index ae12e60..0000000 Binary files a/public/mirror/assets/favicon-32x32.png and /dev/null differ diff --git a/public/mirror/assets/favicon.ico b/public/mirror/assets/favicon.ico deleted file mode 100644 index da17cd5..0000000 Binary files a/public/mirror/assets/favicon.ico and /dev/null differ diff --git a/public/mirror/assets/logo--brand.svg b/public/mirror/assets/logo--brand.svg deleted file mode 100644 index 1b84f50..0000000 --- a/public/mirror/assets/logo--brand.svg +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/mirror/index.html b/public/mirror/index.html deleted file mode 100644 index 64746fe..0000000 --- a/public/mirror/index.html +++ /dev/null @@ -1,521 +0,0 @@ - - - - - - TRMNL BYOS Laravel Mirror - - - - - - - - - - - - - - - - - - - -
-
- - - -
- - - \ No newline at end of file diff --git a/public/mirror/manifest.json b/public/mirror/manifest.json deleted file mode 100644 index 4d44e44..0000000 --- a/public/mirror/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "TRMNL BYOS Laravel Mirror", - "short_name": "TRMNL BYOS", - "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#ffffff" -} diff --git a/rector.php b/rector.php deleted file mode 100644 index dde2f14..0000000 --- a/rector.php +++ /dev/null @@ -1,26 +0,0 @@ -paths([ - __DIR__.'/app', - __DIR__.'/tests', - ]); - - $rectorConfig->sets([ - LevelSetList::UP_TO_PHP_82, - SetList::CODE_QUALITY, - SetList::DEAD_CODE, - SetList::EARLY_RETURN, - SetList::TYPE_DECLARATION, - ]); - - $rectorConfig->skip([ - // Skip any specific rules if needed - ]); -}; diff --git a/resources/css/app.css b/resources/css/app.css index de95b81..46b9ca1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -59,10 +59,6 @@ @apply !mb-0 !leading-tight; } -[data-flux-description] a { - @apply text-accent underline hover:opacity-80; -} - input:focus[data-flux-control], textarea:focus[data-flux-control], select:focus[data-flux-control] { @@ -72,39 +68,3 @@ select:focus[data-flux-control] { /* \[:where(&)\]:size-4 { @apply size-4; } */ - -@layer components { - /* standard container for app */ - .styled-container, - .tab-button { - @apply rounded-xl border bg-white dark:bg-stone-950 dark:border-zinc-700 text-stone-800 shadow-xs; - } - - .tab-button { - @apply flex items-center gap-2 px-4 py-2 text-sm font-medium; - @apply rounded-b-none shadow-none bg-inherit; - - /* This makes the button sit slightly over the box border */ - margin-bottom: -1px; - position: relative; - z-index: 1; - } - - .tab-button.is-active { - @apply text-zinc-700 dark:text-zinc-300; - @apply border-b-white dark:border-b-zinc-800; - - /* Z-index 10 ensures the bottom border of the tab hides the top border of the box */ - z-index: 10; - } - - .tab-button:not(.is-active) { - @apply text-zinc-500 border-transparent; - } - - .tab-button:not(.is-active):hover { - @apply text-zinc-700 dark:text-zinc-300; - @apply border-zinc-300 dark:border-zinc-700; - cursor: pointer; - } -} diff --git a/resources/js/app.js b/resources/js/app.js index db3ebf3..e69de29 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,3 +0,0 @@ -import { codeEditorFormComponent } from './codemirror-alpine.js'; - -window.codeEditorFormComponent = codeEditorFormComponent; diff --git a/resources/js/codemirror-alpine.js b/resources/js/codemirror-alpine.js deleted file mode 100644 index 9ce12f1..0000000 --- a/resources/js/codemirror-alpine.js +++ /dev/null @@ -1,198 +0,0 @@ -import { createCodeMirror, getSystemTheme, watchThemeChange } from './codemirror-core.js'; -import { EditorView } from '@codemirror/view'; - -/** - * Alpine.js component for CodeMirror that integrates with textarea and Livewire - * Inspired by Filament's approach with proper state entanglement - * @param {Object} config - Configuration object - * @returns {Object} Alpine.js component object - */ -export function codeEditorFormComponent(config) { - return { - editor: null, - textarea: null, - isLoading: false, - unwatchTheme: null, - - // Configuration - isDisabled: config.isDisabled || false, - language: config.language || 'html', - state: config.state || '', - textareaId: config.textareaId || null, - - /** - * Initialize the component - */ - async init() { - this.isLoading = true; - - try { - // Wait for textarea if provided - if (this.textareaId) { - await this.waitForTextarea(); - } - - await this.$nextTick(); - this.createEditor(); - this.setupEventListeners(); - } finally { - this.isLoading = false; - } - }, - - /** - * Wait for textarea to be available in the DOM - */ - async waitForTextarea() { - let attempts = 0; - const maxAttempts = 50; // 5 seconds max wait - - while (attempts < maxAttempts) { - this.textarea = document.getElementById(this.textareaId); - if (this.textarea) { - return; - } - - // Wait 100ms before trying again - await new Promise(resolve => setTimeout(resolve, 100)); - attempts++; - } - - console.error(`Textarea with ID "${this.textareaId}" not found after ${maxAttempts} attempts`); - }, - - /** - * Update both Livewire state and textarea with new value - */ - updateState(value) { - this.state = value; - if (this.textarea) { - this.textarea.value = value; - this.textarea.dispatchEvent(new Event('input', { bubbles: true })); - } - }, - - /** - * Create the CodeMirror editor instance - */ - createEditor() { - // Clean up any existing editor first - if (this.editor) { - this.editor.destroy(); - } - - const effectiveTheme = this.getEffectiveTheme(); - const initialValue = this.textarea ? this.textarea.value : this.state; - - this.editor = createCodeMirror(this.$refs.editor, { - value: initialValue || '', - language: this.language, - theme: effectiveTheme, - readOnly: this.isDisabled, - onChange: (value) => this.updateState(value), - onUpdate: (value) => this.updateState(value), - onBlur: () => { - if (this.editor) { - this.updateState(this.editor.state.doc.toString()); - } - } - }); - }, - - /** - * Get effective theme - */ - getEffectiveTheme() { - return getSystemTheme(); - }, - - /** - * Update editor content with new value - */ - updateEditorContent(value) { - if (this.editor && value !== this.editor.state.doc.toString()) { - this.editor.dispatch({ - changes: { - from: 0, - to: this.editor.state.doc.length, - insert: value - } - }); - } - }, - - /** - * Setup event listeners for theme changes and state synchronization - */ - setupEventListeners() { - // Watch for state changes from Livewire - this.$watch('state', (newValue) => { - this.updateEditorContent(newValue); - }); - - // Watch for disabled state changes - this.$watch('isDisabled', (newValue) => { - if (this.editor) { - this.editor.dispatch({ - effects: EditorView.editable.reconfigure(!newValue) - }); - } - }); - - // Watch for textarea changes (from Livewire updates) - if (this.textarea) { - this.textarea.addEventListener('input', (event) => { - this.updateEditorContent(event.target.value); - this.state = event.target.value; - }); - - // Listen for Livewire updates that might change the textarea value - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'attributes' && mutation.attributeName === 'value') { - this.updateEditorContent(this.textarea.value); - this.state = this.textarea.value; - } - }); - }); - - observer.observe(this.textarea, { - attributes: true, - attributeFilter: ['value'] - }); - } - - // Listen for theme changes - this.unwatchTheme = watchThemeChange(() => { - this.recreateEditor(); - }); - }, - - /** - * Recreate the editor (useful for theme changes) - */ - async recreateEditor() { - if (this.editor) { - this.editor.destroy(); - this.editor = null; - await this.$nextTick(); - this.createEditor(); - } - }, - - - /** - * Clean up resources when component is destroyed - */ - destroy() { - if (this.editor) { - this.editor.destroy(); - this.editor = null; - } - if (this.unwatchTheme) { - this.unwatchTheme(); - } - } - }; -} - diff --git a/resources/js/codemirror-core.js b/resources/js/codemirror-core.js deleted file mode 100644 index f23389f..0000000 --- a/resources/js/codemirror-core.js +++ /dev/null @@ -1,265 +0,0 @@ -import { EditorView, lineNumbers, keymap } from '@codemirror/view'; -import { ViewPlugin } from '@codemirror/view'; -import { indentWithTab, selectAll } from '@codemirror/commands'; -import { foldGutter, foldKeymap } from '@codemirror/language'; -import { history, historyKeymap } from '@codemirror/commands'; -import { searchKeymap } from '@codemirror/search'; -import { html } from '@codemirror/lang-html'; -import { javascript } from '@codemirror/lang-javascript'; -import { json } from '@codemirror/lang-json'; -import { css } from '@codemirror/lang-css'; -import { liquid } from '@codemirror/lang-liquid'; -import { oneDark } from '@codemirror/theme-one-dark'; -import { githubLight } from '@fsegurai/codemirror-theme-github-light'; - -// Language support mapping -const LANGUAGE_MAP = { - 'javascript': javascript, - 'js': javascript, - 'json': json, - 'css': css, - 'liquid': liquid, - 'html': html, -}; - -// Theme support mapping -const THEME_MAP = { - 'light': githubLight, - 'dark': oneDark, -}; - -/** - * Get language support based on language parameter - * @param {string} language - Language name or comma-separated list - * @returns {Array|Extension} Language extension(s) - */ -function getLanguageSupport(language) { - // Handle comma-separated languages - if (language.includes(',')) { - const languages = language.split(',').map(lang => lang.trim().toLowerCase()); - const languageExtensions = []; - - languages.forEach(lang => { - const languageFn = LANGUAGE_MAP[lang]; - if (languageFn) { - languageExtensions.push(languageFn()); - } - }); - - return languageExtensions; - } - - // Handle single language - const languageFn = LANGUAGE_MAP[language.toLowerCase()] || LANGUAGE_MAP.html; - return languageFn(); -} - -/** - * Get theme support - * @param {string} theme - Theme name - * @returns {Array} Theme extensions - */ -function getThemeSupport(theme) { - const themeFn = THEME_MAP[theme] || THEME_MAP.light; - return [themeFn]; -} - -/** - * Create a resize plugin that handles container resizing - * @returns {ViewPlugin} Resize plugin - */ -function createResizePlugin() { - return ViewPlugin.fromClass(class { - constructor(view) { - this.view = view; - this.resizeObserver = null; - this.setupResizeObserver(); - } - - setupResizeObserver() { - const container = this.view.dom.parentElement; - if (container) { - this.resizeObserver = new ResizeObserver(() => { - // Use requestAnimationFrame to ensure proper timing - requestAnimationFrame(() => { - this.view.requestMeasure(); - }); - }); - this.resizeObserver.observe(container); - } - } - - destroy() { - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - } - } - }); -} - -/** - * Get Flux-like theme styling based on theme - * @param {string} theme - Theme name ('light', 'dark', or 'auto') - * @returns {Object} Theme-specific styling - */ -function getFluxThemeStyling(theme) { - const isDark = theme === 'dark' || (theme === 'auto' && getSystemTheme() === 'dark'); - - if (isDark) { - return { - backgroundColor: 'oklab(0.999994 0.0000455678 0.0000200868 / 0.1)', - gutterBackgroundColor: 'oklch(26.9% 0 0)', - borderColor: '#374151', - focusBorderColor: 'rgb(224 91 68)', - }; - } else { - return { - backgroundColor: '#fff', // zinc-50 - gutterBackgroundColor: '#fafafa', // zinc-50 - borderColor: '#e5e7eb', // gray-200 - focusBorderColor: 'rgb(224 91 68)', // red-500 - }; - } -} - -/** - * Create CodeMirror editor instance - * @param {HTMLElement} element - DOM element to mount editor - * @param {Object} options - Editor options - * @returns {EditorView} CodeMirror editor instance - */ -export function createCodeMirror(element, options = {}) { - const { - value = '', - language = 'html', - theme = 'light', - readOnly = false, - onChange = () => {}, - onUpdate = () => {}, - onBlur = () => {} - } = options; - - // Get language and theme support - const languageSupport = getLanguageSupport(language); - const themeSupport = getThemeSupport(theme); - const fluxStyling = getFluxThemeStyling(theme); - - // Create editor - const editor = new EditorView({ - doc: value, - extensions: [ - lineNumbers(), - foldGutter(), - history(), - EditorView.lineWrapping, - createResizePlugin(), - ...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]), - ...themeSupport, - keymap.of([ - indentWithTab, - ...foldKeymap, - ...historyKeymap, - ...searchKeymap, - { - key: 'Mod-a', - run: selectAll, - }, - ]), - EditorView.theme({ - '&': { - fontSize: '14px', - border: `1px solid ${fluxStyling.borderColor}`, - borderRadius: '0.375rem', - height: '100%', - maxHeight: '100%', - overflow: 'hidden', - backgroundColor: fluxStyling.backgroundColor + ' !important', - resize: 'vertical', - minHeight: '200px', - }, - '.cm-gutters': { - borderTopLeftRadius: '0.375rem', - backgroundColor: fluxStyling.gutterBackgroundColor + ' !important', - }, - '.cm-gutter': { - backgroundColor: fluxStyling.gutterBackgroundColor + ' !important', - }, - '&.cm-focused': { - outline: 'none', - borderColor: fluxStyling.focusBorderColor, - }, - '.cm-content': { - padding: '12px', - }, - '.cm-scroller': { - fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - height: '100%', - overflow: 'auto', - }, - '.cm-editor': { - height: '100%', - }, - '.cm-editor .cm-scroller': { - height: '100%', - overflow: 'auto', - }, - '.cm-foldGutter': { - width: '12px', - }, - '.cm-foldGutter .cm-gutterElement': { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - cursor: 'pointer', - fontSize: '12px', - color: '#6b7280', - }, - '.cm-foldGutter .cm-gutterElement:hover': { - color: '#374151', - }, - '.cm-foldGutter .cm-gutterElement.cm-folded': { - color: '#3b82f6', - } - }), - EditorView.updateListener.of((update) => { - if (update.docChanged) { - const newValue = update.state.doc.toString(); - onChange(newValue); - onUpdate(newValue); - } - }), - EditorView.domEventHandlers({ - blur: onBlur - }), - EditorView.editable.of(!readOnly), - ], - parent: element - }); - - return editor; -} - -/** - * Auto-detect system theme preference - * @returns {string} 'dark' or 'light' - */ -export function getSystemTheme() { - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - return 'dark'; - } - return 'light'; -} - -/** - * Watch for system theme changes - * @param {Function} callback - Callback function when theme changes - * @returns {Function} Unwatch function - */ -export function watchThemeChange(callback) { - if (window.matchMedia) { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - mediaQuery.addEventListener('change', callback); - return () => mediaQuery.removeEventListener('change', callback); - } - return () => {}; -} diff --git a/resources/views/components/layouts/auth/card.blade.php b/resources/views/components/layouts/auth/card.blade.php index b5a62c6..1a316ef 100644 --- a/resources/views/components/layouts/auth/card.blade.php +++ b/resources/views/components/layouts/auth/card.blade.php @@ -15,7 +15,7 @@
-
+
{{ $slot }}
diff --git a/resources/views/default-screens/error.blade.php b/resources/views/default-screens/error.blade.php deleted file mode 100644 index be8063a..0000000 --- a/resources/views/default-screens/error.blade.php +++ /dev/null @@ -1,23 +0,0 @@ -@props([ - 'noBleed' => false, - 'darkMode' => false, - 'deviceVariant' => 'og', - 'deviceOrientation' => null, - 'colorDepth' => '1bit', - 'scaleLevel' => null, - 'pluginName' => 'Recipe', -]) - - - - - - Error on {{ $pluginName }} - Unable to render content. Please check server logs. - - - - - diff --git a/resources/views/default-screens/setup.blade.php b/resources/views/default-screens/setup.blade.php deleted file mode 100644 index 3b0ff05..0000000 --- a/resources/views/default-screens/setup.blade.php +++ /dev/null @@ -1,22 +0,0 @@ -@props([ - 'noBleed' => false, - 'darkMode' => false, - 'deviceVariant' => 'og', - 'deviceOrientation' => null, - 'colorDepth' => '1bit', - 'scaleLevel' => null, -]) - - - - - - Welcome to BYOS Laravel! - Your device is connected. - - - - - diff --git a/resources/views/default-screens/sleep.blade.php b/resources/views/default-screens/sleep.blade.php deleted file mode 100644 index 89d6baa..0000000 --- a/resources/views/default-screens/sleep.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -@props([ - 'noBleed' => false, - 'darkMode' => true, - 'deviceVariant' => 'og', - 'deviceOrientation' => null, - 'colorDepth' => '1bit', - 'scaleLevel' => null, -]) - - - - - -
- - - -
- Sleep Mode -
-
- -
-
diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php deleted file mode 100644 index fdf7f34..0000000 --- a/resources/views/livewire/catalog/index.blade.php +++ /dev/null @@ -1,268 +0,0 @@ -loadCatalogPlugins(); - } - - public function placeholder() - { - return <<<'HTML' -
-
-
- - Loading recipes... -
-
-
- HTML; - } - - private function loadCatalogPlugins(): void - { - $catalogUrl = config('app.catalog_url'); - - $this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) { - try { - $response = Http::timeout(10)->get($catalogUrl); - $catalogContent = $response->body(); - $catalog = Yaml::parse($catalogContent); - - $currentVersion = config('app.version'); - - return collect($catalog) - ->filter(function ($plugin) use ($currentVersion) { - // Check if Laravel compatibility is true - if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) { - return false; - } - - // Check minimum version if specified - $minVersion = Arr::get($plugin, 'byos.byos_laravel.min_version'); - if ($minVersion && $currentVersion && version_compare($currentVersion, $minVersion, '<')) { - return false; - } - - return true; - }) - ->map(function ($plugin, $key) { - return [ - 'id' => $key, - 'name' => Arr::get($plugin, 'name', 'Unknown Plugin'), - 'description' => Arr::get($plugin, 'author_bio.description', ''), - 'author' => Arr::get($plugin, 'author.name', 'Unknown Author'), - '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'), - 'learn_more_url' => Arr::get($plugin, 'author_bio.learn_more_url'), - ]; - }) - ->sortBy('name') - ->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(), - $plugin['zip_entry_path'] ?? null, - null, - $plugin['logo_url'] ?? null, - allowDuplicate: true - ); - - $this->dispatch('plugin-installed'); - Flux::modal('import-from-catalog')->close(); - - } catch (Exception $e) { - $this->addError('installation', 'Error installing plugin: '.$e->getMessage()); - } finally { - $this->installingPlugin = ''; - } - } - - public function previewPlugin(string $pluginId): void - { - $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); - - if (! $plugin) { - $this->addError('preview', 'Plugin not found.'); - - return; - } - - $this->previewingPlugin = $pluginId; - $this->previewData = $plugin; - } - - public function closePreview(): void - { - $this->previewingPlugin = ''; - $this->previewData = []; - } -}; ?> - -
- @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['screenshot_url']) - - - Preview - - - @endif - - - - @if($plugin['learn_more_url']) - - Learn More - - @endif -
-
-
-
- @endforeach -
- @endif - - - - @if($previewingPlugin && !empty($previewData)) -
- Preview {{ $previewData['name'] ?? 'Plugin' }} -
- -
-
- Preview of {{ $previewData['name'] }} -
- - @if($previewData['description']) -
- Description - {{ $previewData['description'] }} -
- @endif - -
- - - Install Plugin - - -
-
- @endif -
-
diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php deleted file mode 100644 index cc8b070..0000000 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ /dev/null @@ -1,407 +0,0 @@ -loadNewest(); - } - - public function placeholder() - { - return <<<'HTML' -
-
-
- - Loading recipes... -
-
-
- HTML; - } - - private function loadNewest(): void - { - try { - $cacheKey = 'trmnl_recipes_newest_page_'.$this->page; - $response = Cache::remember($cacheKey, 43200, function () { - $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ - 'sort-by' => 'newest', - 'page' => $this->page, - ]); - - if (! $response->successful()) { - throw new RuntimeException('Failed to fetch TRMNL recipes'); - } - - return $response->json(); - }); - - $data = $response['data'] ?? []; - $mapped = $this->mapRecipes($data); - - if ($this->page === 1) { - $this->recipes = $mapped; - } else { - $this->recipes = array_merge($this->recipes, $mapped); - } - - $this->hasMore = ! empty($response['next_page_url']); - } catch (Throwable $e) { - Log::error('TRMNL catalog load error: '.$e->getMessage()); - if ($this->page === 1) { - $this->recipes = []; - } - $this->hasMore = false; - } - } - - private function searchRecipes(string $term): void - { - $this->isSearching = true; - try { - $cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page; - $response = Cache::remember($cacheKey, 300, function () use ($term) { - $response = Http::get('https://usetrmnl.com/recipes.json', [ - 'search' => $term, - 'sort-by' => 'newest', - 'page' => $this->page, - ]); - - if (! $response->successful()) { - throw new RuntimeException('Failed to search TRMNL recipes'); - } - - return $response->json(); - }); - - $data = $response['data'] ?? []; - $mapped = $this->mapRecipes($data); - - if ($this->page === 1) { - $this->recipes = $mapped; - } else { - $this->recipes = array_merge($this->recipes, $mapped); - } - - $this->hasMore = ! empty($response['next_page_url']); - } catch (Throwable $e) { - Log::error('TRMNL catalog search error: '.$e->getMessage()); - if ($this->page === 1) { - $this->recipes = []; - } - $this->hasMore = false; - } finally { - $this->isSearching = false; - } - } - - public function loadMore(): void - { - $this->page++; - - $term = mb_trim($this->search); - if ($term === '' || mb_strlen($term) < 2) { - $this->loadNewest(); - } else { - $this->searchRecipes($term); - } - } - - public function updatedSearch(): void - { - $this->page = 1; - $term = mb_trim($this->search); - if ($term === '') { - $this->loadNewest(); - - return; - } - - if (mb_strlen($term) < 2) { - // Require at least 2 chars to avoid noisy calls - return; - } - - $this->searchRecipes($term); - } - - public function installPlugin(string $recipeId, PluginImportService $pluginImportService): void - { - abort_unless(auth()->user() !== null, 403); - - try { - $zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive"; - - $recipe = collect($this->recipes)->firstWhere('id', $recipeId); - - $plugin = $pluginImportService->importFromUrl( - $zipUrl, - auth()->user(), - null, - config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null, - $recipe['icon_url'] ?? null, - allowDuplicate: true - ); - - $this->dispatch('plugin-installed'); - Flux::modal('import-from-trmnl-catalog')->close(); - - } catch (Exception $e) { - Log::error('Plugin installation failed: '.$e->getMessage()); - $this->addError('installation', 'Error installing plugin: '.$e->getMessage()); - } - } - - public function previewRecipe(string $recipeId): void - { - $this->previewingRecipe = $recipeId; - $this->previewData = []; - - try { - $response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json"); - - if ($response->successful()) { - $item = $response->json()['data'] ?? []; - $this->previewData = $this->mapRecipe($item); - } else { - // Fallback to searching for the specific recipe if single endpoint doesn't exist - $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ - 'search' => $recipeId, - ]); - - if ($response->successful()) { - $data = $response->json()['data'] ?? []; - $item = collect($data)->firstWhere('id', $recipeId); - if ($item) { - $this->previewData = $this->mapRecipe($item); - } - } - } - } catch (Throwable $e) { - Log::error('TRMNL catalog preview fetch error: '.$e->getMessage()); - } - - if (empty($this->previewData)) { - $this->previewData = collect($this->recipes)->firstWhere('id', $recipeId) ?? []; - } - } - - /** - * @param array> $items - * @return array> - */ - private function mapRecipes(array $items): array - { - return collect($items) - ->map(fn (array $item) => $this->mapRecipe($item)) - ->toArray(); - } - - /** - * @param array $item - * @return array - */ - private function mapRecipe(array $item): array - { - return [ - 'id' => $item['id'] ?? null, - 'name' => $item['name'] ?? 'Untitled', - 'icon_url' => $item['icon_url'] ?? null, - 'screenshot_url' => $item['screenshot_url'] ?? null, - 'author_bio' => is_array($item['author_bio'] ?? null) - ? strip_tags($item['author_bio']['description'] ?? null) - : null, - 'stats' => [ - 'installs' => data_get($item, 'stats.installs'), - 'forks' => data_get($item, 'stats.forks'), - ], - 'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null, - ]; - } -}; ?> - -
-
-
- -
- Newest -
- - @error('installation') - - @enderror - - @if(empty($recipes)) -
- - No recipes found - Try a different search term -
- @else -
- @foreach($recipes as $recipe) -
-
-
- @php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) - @if($thumb) - {{ $recipe['name'] }} - @else -
- -
- @endif - -
-
-
- {{ $recipe['name'] }} - @if(data_get($recipe, 'stats.installs')) - Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} - @endif -
-
- @if($recipe['detail_url']) - - - - @endif -
-
- - @if($recipe['author_bio']) - {{ $recipe['author_bio'] }} - @endif - -
- @if($recipe['id']) - - Install - - @endif - - @if($recipe['id'] && ($recipe['screenshot_url'] ?? null)) - - - Preview - - - @endif -
-
-
-
-
- @endforeach -
- - @if($hasMore) -
- - Load next page - Loading... - -
- @endif - @endif - - - -
-
- - Fetching recipe details... -
-
- -
- @if($previewingRecipe && !empty($previewData)) -
- Preview {{ $previewData['name'] ?? 'Recipe' }} -
- -
-
- Preview of {{ $previewData['name'] }} -
- - @if($previewData['author_bio']) -
-
- Description - {{ $previewData['author_bio'] }} -
-
- @endif - - @if(data_get($previewData, 'stats.installs')) -
-
- Statistics - - Installs: {{ data_get($previewData, 'stats.installs') }} · - Forks: {{ data_get($previewData, 'stats.forks') }} - -
-
- @endif - -
- @if($previewData['detail_url']) - - View on TRMNL - - @endif - - - Install Recipe - - -
-
- @endif -
-
-
diff --git a/resources/views/livewire/codemirror.blade.php b/resources/views/livewire/codemirror.blade.php deleted file mode 100644 index fad3e53..0000000 --- a/resources/views/livewire/codemirror.blade.php +++ /dev/null @@ -1,64 +0,0 @@ -language = $language; - $this->theme = $theme; - $this->readonly = $readonly; - $this->placeholder = $placeholder; - $this->height = $height; - $this->id = $id; - } - - - public function toJSON() - { - return json_encode([ - 'model' => $this->model, - 'language' => $this->language, - 'theme' => $this->theme, - 'readonly' => $this->readonly, - 'placeholder' => $this->placeholder, - 'height' => $this->height, - 'id' => $this->id, - ]); - } -} ?> - - -
- -
-
- - - - - Loading editor... -
-
- - -
-
diff --git a/resources/views/livewire/device-dashboard.blade.php b/resources/views/livewire/device-dashboard.blade.php index 7fd48a8..5db65d1 100644 --- a/resources/views/livewire/device-dashboard.blade.php +++ b/resources/views/livewire/device-dashboard.blade.php @@ -16,7 +16,7 @@ new class extends Component { @if($devices->isEmpty())
+ class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">

Add your first device

+ class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
@php $current_image_uuid =$device->current_screen_image; diff --git a/resources/views/livewire/device-models/index.blade.php b/resources/views/livewire/device-models/index.blade.php index a57085b..a78f2a2 100644 --- a/resources/views/livewire/device-models/index.blade.php +++ b/resources/views/livewire/device-models/index.blade.php @@ -1,43 +1,26 @@ 'required|string|max:255|unique:device_models,name', 'label' => 'required|string|max:255', @@ -57,144 +40,42 @@ new class extends Component public function mount() { $this->deviceModels = DeviceModel::all(); - $this->devicePalettes = DevicePalette::all(); - return view('livewire.device-models.index'); } + public function createDeviceModel(): void + { + $this->validate(); + + DeviceModel::create([ + 'name' => $this->name, + 'label' => $this->label, + 'description' => $this->description, + 'width' => $this->width, + 'height' => $this->height, + 'colors' => $this->colors, + 'bit_depth' => $this->bit_depth, + 'scale_factor' => $this->scale_factor, + 'rotation' => $this->rotation, + 'mime_type' => $this->mime_type, + 'offset_x' => $this->offset_x, + 'offset_y' => $this->offset_y, + 'published_at' => $this->published_at, + ]); + + $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at']); + \Flux::modal('create-device-model')->close(); + + $this->deviceModels = DeviceModel::all(); + session()->flash('message', 'Device model created successfully.'); + } + public $editingDeviceModelId; - public $viewingDeviceModelId; - - public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void + public function editDeviceModel(DeviceModel $deviceModel): void { - if ($deviceModelId) { - $deviceModel = DeviceModel::findOrFail($deviceModelId); - - if ($viewOnly) { - $this->viewingDeviceModelId = $deviceModel->id; - $this->editingDeviceModelId = null; - } else { - $this->editingDeviceModelId = $deviceModel->id; - $this->viewingDeviceModelId = null; - } - - $this->name = $deviceModel->name; - $this->label = $deviceModel->label; - $this->description = $deviceModel->description; - $this->width = $deviceModel->width; - $this->height = $deviceModel->height; - $this->colors = $deviceModel->colors; - $this->bit_depth = $deviceModel->bit_depth; - $this->scale_factor = $deviceModel->scale_factor; - $this->rotation = $deviceModel->rotation; - $this->mime_type = $deviceModel->mime_type; - $this->offset_x = $deviceModel->offset_x; - $this->offset_y = $deviceModel->offset_y; - $this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i'); - $this->palette_id = $deviceModel->palette_id; - } else { - $this->editingDeviceModelId = null; - $this->viewingDeviceModelId = null; - $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id']); - $this->mime_type = 'image/png'; - $this->scale_factor = 1.0; - $this->rotation = 0; - $this->offset_x = 0; - $this->offset_y = 0; - } - } - - public function saveDeviceModel(): void - { - $rules = [ - 'name' => 'required|string|max:255', - 'label' => 'required|string|max:255', - 'description' => 'required|string', - 'width' => 'required|integer|min:1', - 'height' => 'required|integer|min:1', - 'colors' => 'required|integer|min:1', - 'bit_depth' => 'required|integer|min:1', - 'scale_factor' => 'required|numeric|min:0.1', - 'rotation' => 'required|integer', - 'mime_type' => 'required|string|max:255', - 'offset_x' => 'required|integer', - 'offset_y' => 'required|integer', - 'published_at' => 'nullable|date', - 'palette_id' => 'nullable|exists:device_palettes,id', - ]; - - if ($this->editingDeviceModelId) { - $rules['name'] = 'required|string|max:255|unique:device_models,name,'.$this->editingDeviceModelId; - } else { - $rules['name'] = 'required|string|max:255|unique:device_models,name'; - } - - $this->validate($rules); - - if ($this->editingDeviceModelId) { - $deviceModel = DeviceModel::findOrFail($this->editingDeviceModelId); - $deviceModel->update([ - 'name' => $this->name, - 'label' => $this->label, - 'description' => $this->description, - 'width' => $this->width, - 'height' => $this->height, - 'colors' => $this->colors, - 'bit_depth' => $this->bit_depth, - 'scale_factor' => $this->scale_factor, - 'rotation' => $this->rotation, - 'mime_type' => $this->mime_type, - 'offset_x' => $this->offset_x, - 'offset_y' => $this->offset_y, - 'published_at' => $this->published_at, - 'palette_id' => $this->palette_id ?: null, - ]); - $message = 'Device model updated successfully.'; - } else { - DeviceModel::create([ - 'name' => $this->name, - 'label' => $this->label, - 'description' => $this->description, - 'width' => $this->width, - 'height' => $this->height, - 'colors' => $this->colors, - 'bit_depth' => $this->bit_depth, - 'scale_factor' => $this->scale_factor, - 'rotation' => $this->rotation, - 'mime_type' => $this->mime_type, - 'offset_x' => $this->offset_x, - 'offset_y' => $this->offset_y, - 'published_at' => $this->published_at, - 'palette_id' => $this->palette_id ?: null, - 'source' => 'manual', - ]); - $message = 'Device model created successfully.'; - } - - $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'editingDeviceModelId', 'viewingDeviceModelId']); - Flux::modal('device-model-modal')->close(); - - $this->deviceModels = DeviceModel::all(); - session()->flash('message', $message); - } - - public function deleteDeviceModel(string $deviceModelId): void - { - $deviceModel = DeviceModel::findOrFail($deviceModelId); - $deviceModel->delete(); - - $this->deviceModels = DeviceModel::all(); - session()->flash('message', 'Device model deleted successfully.'); - } - - public function duplicateDeviceModel(string $deviceModelId): void - { - $deviceModel = DeviceModel::findOrFail($deviceModelId); - - $this->editingDeviceModelId = null; - $this->viewingDeviceModelId = null; - $this->name = $deviceModel->name.' (Copy)'; + $this->editingDeviceModelId = $deviceModel->id; + $this->name = $deviceModel->name; $this->label = $deviceModel->label; $this->description = $deviceModel->description; $this->width = $deviceModel->width; @@ -207,9 +88,57 @@ new class extends Component $this->offset_x = $deviceModel->offset_x; $this->offset_y = $deviceModel->offset_y; $this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i'); - $this->palette_id = $deviceModel->palette_id; + } - $this->js('Flux.modal("device-model-modal").show()'); + public function updateDeviceModel(): void + { + $deviceModel = DeviceModel::findOrFail($this->editingDeviceModelId); + + $this->validate([ + 'name' => 'required|string|max:255|unique:device_models,name,' . $deviceModel->id, + 'label' => 'required|string|max:255', + 'description' => 'required|string', + 'width' => 'required|integer|min:1', + 'height' => 'required|integer|min:1', + 'colors' => 'required|integer|min:1', + 'bit_depth' => 'required|integer|min:1', + 'scale_factor' => 'required|numeric|min:0.1', + 'rotation' => 'required|integer', + 'mime_type' => 'required|string|max:255', + 'offset_x' => 'required|integer', + 'offset_y' => 'required|integer', + 'published_at' => 'nullable|date', + ]); + + $deviceModel->update([ + 'name' => $this->name, + 'label' => $this->label, + 'description' => $this->description, + 'width' => $this->width, + 'height' => $this->height, + 'colors' => $this->colors, + 'bit_depth' => $this->bit_depth, + 'scale_factor' => $this->scale_factor, + 'rotation' => $this->rotation, + 'mime_type' => $this->mime_type, + 'offset_x' => $this->offset_x, + 'offset_y' => $this->offset_y, + 'published_at' => $this->published_at, + ]); + + $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'editingDeviceModelId']); + \Flux::modal('edit-device-model-' . $deviceModel->id)->close(); + + $this->deviceModels = DeviceModel::all(); + session()->flash('message', 'Device model updated successfully.'); + } + + public function deleteDeviceModel(DeviceModel $deviceModel): void + { + $deviceModel->delete(); + + $this->deviceModels = DeviceModel::all(); + session()->flash('message', 'Device model deleted successfully.'); } } @@ -219,19 +148,10 @@ new class extends Component
-
-

Device Models

- - - - Devices - Device Palettes - - -
- - Add Device Model - +

Device Models

+ {{-- --}} + {{-- Add Device Model--}} + {{-- --}}
@if (session()->has('message'))
@@ -244,104 +164,157 @@ new class extends Component
@endif - +
- - @if ($viewingDeviceModelId) - View Device Model - @elseif ($editingDeviceModelId) - Edit Device Model - @else - Add Device Model - @endif - + Add Device Model
-
+
+ name="name" autofocus/>
+ name="label"/>
+ class="block mt-1 w-full" name="description"/>
+ name="width"/> + name="height"/>
+ name="colors"/> + name="bit_depth"/>
+ name="scale_factor" step="0.1"/> + name="rotation"/>
- - image/png - image/bmp - +
+ name="offset_x"/> + name="offset_y"/>
-
- - None - @foreach ($devicePalettes as $palette) - {{ $palette->description ?? $palette->name }} ({{ $palette->name }}) - @endforeach - +
+ + Create Device Model
- - @if (!$viewingDeviceModelId) -
- - {{ $editingDeviceModelId ? 'Update' : 'Create' }} Device Model -
- @else -
- - Duplicate -
- @endif
+ @foreach ($deviceModels as $deviceModel) + +
+
+ Edit Device Model +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + image/png + image/bmp + + +
+ +
+ + +
+ +
+ + Update Device Model +
+
+
+
+ @endforeach + @@ -396,25 +369,14 @@ new class extends Component >
- @if ($deviceModel->source === 'api') - - - - - + - @else - - - - - - - @endif + + +
diff --git a/resources/views/livewire/device-palettes/index.blade.php b/resources/views/livewire/device-palettes/index.blade.php deleted file mode 100644 index 28f99c9..0000000 --- a/resources/views/livewire/device-palettes/index.blade.php +++ /dev/null @@ -1,384 +0,0 @@ - 'required|string|max:255|unique:device_palettes,name', - 'description' => 'nullable|string|max:255', - 'grays' => 'required|integer|min:1|max:256', - 'colors' => 'nullable|array', - 'colors.*' => 'string|regex:/^#[0-9A-Fa-f]{6}$/', - 'framework_class' => 'nullable|string|max:255', - ]; - - public function mount() - { - $this->devicePalettes = DevicePalette::all(); - - return view('livewire.device-palettes.index'); - } - - public function addColor(): void - { - $this->validate(['colorInput' => 'required|string|regex:/^#[0-9A-Fa-f]{6}$/'], [ - 'colorInput.regex' => 'Color must be a valid hex color (e.g., #FF0000)', - ]); - - if (! in_array($this->colorInput, $this->colors)) { - $this->colors[] = $this->colorInput; - } - - $this->colorInput = ''; - } - - public function removeColor(int $index): void - { - unset($this->colors[$index]); - $this->colors = array_values($this->colors); - } - - public $editingDevicePaletteId; - - public $viewingDevicePaletteId; - - public function openDevicePaletteModal(?string $devicePaletteId = null, bool $viewOnly = false): void - { - if ($devicePaletteId) { - $devicePalette = DevicePalette::findOrFail($devicePaletteId); - - if ($viewOnly) { - $this->viewingDevicePaletteId = $devicePalette->id; - $this->editingDevicePaletteId = null; - } else { - $this->editingDevicePaletteId = $devicePalette->id; - $this->viewingDevicePaletteId = null; - } - - $this->name = $devicePalette->name; - $this->description = $devicePalette->description; - $this->grays = $devicePalette->grays; - - // Ensure colors is always an array and properly decoded - // The model cast should handle JSON decoding, but we'll be explicit - $colors = $devicePalette->getAttribute('colors'); - - if ($colors === null) { - $this->colors = []; - } elseif (is_string($colors)) { - $decoded = json_decode($colors, true); - $this->colors = is_array($decoded) ? array_values($decoded) : []; - } elseif (is_array($colors)) { - $this->colors = array_values($colors); // Re-index array - } else { - $this->colors = []; - } - - $this->framework_class = $devicePalette->framework_class; - } else { - $this->editingDevicePaletteId = null; - $this->viewingDevicePaletteId = null; - $this->reset(['name', 'description', 'grays', 'colors', 'framework_class']); - } - - $this->colorInput = ''; - } - - public function saveDevicePalette(): void - { - $rules = [ - 'name' => 'required|string|max:255', - 'description' => 'nullable|string|max:255', - 'grays' => 'required|integer|min:1|max:256', - 'colors' => 'nullable|array', - 'colors.*' => 'string|regex:/^#[0-9A-Fa-f]{6}$/', - 'framework_class' => 'nullable|string|max:255', - ]; - - if ($this->editingDevicePaletteId) { - $rules['name'] = 'required|string|max:255|unique:device_palettes,name,'.$this->editingDevicePaletteId; - } else { - $rules['name'] = 'required|string|max:255|unique:device_palettes,name'; - } - - $this->validate($rules); - - if ($this->editingDevicePaletteId) { - $devicePalette = DevicePalette::findOrFail($this->editingDevicePaletteId); - $devicePalette->update([ - 'name' => $this->name, - 'description' => $this->description, - 'grays' => $this->grays, - 'colors' => ! empty($this->colors) ? $this->colors : null, - 'framework_class' => $this->framework_class, - ]); - $message = 'Device palette updated successfully.'; - } else { - DevicePalette::create([ - 'name' => $this->name, - 'description' => $this->description, - 'grays' => $this->grays, - 'colors' => ! empty($this->colors) ? $this->colors : null, - 'framework_class' => $this->framework_class, - 'source' => 'manual', - ]); - $message = 'Device palette created successfully.'; - } - - $this->reset(['name', 'description', 'grays', 'colors', 'framework_class', 'colorInput', 'editingDevicePaletteId', 'viewingDevicePaletteId']); - Flux::modal('device-palette-modal')->close(); - - $this->devicePalettes = DevicePalette::all(); - session()->flash('message', $message); - } - - public function deleteDevicePalette(string $devicePaletteId): void - { - $devicePalette = DevicePalette::findOrFail($devicePaletteId); - $devicePalette->delete(); - - $this->devicePalettes = DevicePalette::all(); - session()->flash('message', 'Device palette deleted successfully.'); - } - - public function duplicateDevicePalette(string $devicePaletteId): void - { - $devicePalette = DevicePalette::findOrFail($devicePaletteId); - - $this->editingDevicePaletteId = null; - $this->viewingDevicePaletteId = null; - $this->name = $devicePalette->name.' (Copy)'; - $this->description = $devicePalette->description; - $this->grays = $devicePalette->grays; - - $colors = $devicePalette->getAttribute('colors'); - if ($colors === null) { - $this->colors = []; - } elseif (is_string($colors)) { - $decoded = json_decode($colors, true); - $this->colors = is_array($decoded) ? array_values($decoded) : []; - } elseif (is_array($colors)) { - $this->colors = array_values($colors); - } else { - $this->colors = []; - } - - $this->framework_class = $devicePalette->framework_class; - $this->colorInput = ''; - - $this->js('Flux.modal("device-palette-modal").show()'); - } -} - -?> - -
-
-
-
-
-

Device Palettes

- - - - Devices - Device Models - - -
- - Add Device Palette - -
- @if (session()->has('message')) -
- - - - - -
- @endif - - -
-
- - @if ($viewingDevicePaletteId) - View Device Palette - @elseif ($editingDevicePaletteId) - Edit Device Palette - @else - Add Device Palette - @endif - -
- -
-
- -
- -
- -
- -
- -
- -
- -
- -
- Colors - @if (!$viewingDevicePaletteId) -
- - Add -
- @endif -
- @if (!empty($colors) && is_array($colors) && count($colors) > 0) - @foreach ($colors as $index => $color) - @if (!empty($color)) -
-
- {{ $color }} - @if (!$viewingDevicePaletteId) - - @endif -
- @endif - @endforeach - @endif -
- @if (!$viewingDevicePaletteId) -

Leave empty for grayscale-only palette

- @endif -
- - @if (!$viewingDevicePaletteId) -
- - {{ $editingDevicePaletteId ? 'Update' : 'Create' }} Device Palette -
- @else -
- - Duplicate -
- @endif - -
-
- -
- - - - - - - - - - - @foreach ($devicePalettes as $devicePalette) - - - - - - - @endforeach - -
-
Description
-
-
Grays
-
-
Colors
-
-
Actions
-
-
-
{{ $devicePalette->description ?? $devicePalette->name }}
-
{{ $devicePalette->name }}
-
-
- {{ $devicePalette->grays }} - - @if ($devicePalette->colors) -
- @foreach ($devicePalette->colors as $color) -
- @endforeach - ({{ count($devicePalette->colors) }}) -
- @else - Grayscale only - @endif -
-
- - @if ($devicePalette->source === 'api') - - - - - - - @else - - - - - - - @endif - -
-
-
-
-
- diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index f9d49ca..44e424c 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -309,7 +309,7 @@ new class extends Component {
+ class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
@php $current_image_uuid =$device->current_screen_image; @@ -368,10 +368,6 @@ new class extends Component { Update Firmware Show Logs - - Mirror URL - - Delete Device @@ -387,6 +383,7 @@ new class extends Component { Edit TRMNL
+ - - @php - $mirrorUrl = url('/mirror/index.html') . '?mac_address=' . urlencode($device->mac_address) . '&api_key=' . urlencode($device->api_key); - @endphp - -
-
- Mirror WebUI - Mirror this device onto older devices with a web browser — Safari is supported back to iOS 9. -
- - -
-
- @if(!$device->mirror_device_id) @if($current_image_path) diff --git a/resources/views/livewire/devices/manage.blade.php b/resources/views/livewire/devices/manage.blade.php index 646adc0..d87bd1c 100644 --- a/resources/views/livewire/devices/manage.blade.php +++ b/resources/views/livewire/devices/manage.blade.php @@ -121,16 +121,7 @@ new class extends Component { {{--@dump($devices)--}}
-
-

Devices

- - - - Device Models - Device Palettes - - -
+

Devices

Add Device diff --git a/resources/views/livewire/playlists/index.blade.php b/resources/views/livewire/playlists/index.blade.php index 6c979e6..3e786b4 100644 --- a/resources/views/livewire/playlists/index.blade.php +++ b/resources/views/livewire/playlists/index.blade.php @@ -332,7 +332,7 @@ new class extends Component { @endforeach @if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty())) -
+

No playlists found

Add playlists to your devices to see them here.

diff --git a/resources/views/livewire/plugins/config-modal.blade.php b/resources/views/livewire/plugins/config-modal.blade.php deleted file mode 100644 index 7aaacbb..0000000 --- a/resources/views/livewire/plugins/config-modal.blade.php +++ /dev/null @@ -1,516 +0,0 @@ - loadData(); - } - - public function loadData(): void - { - $this->resetErrorBag(); - // Reload data - $this->plugin = $this->plugin->fresh(); - - $this->configuration_template = $this->plugin->configuration_template ?? []; - $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : []; - - // Initialize multiValues by exploding the CSV strings from the DB - foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { - if (($field['field_type'] ?? null) === 'multi_string') { - $fieldKey = $field['keyname']; - $rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? ''); - - $currentValue = is_array($rawValue) ? '' : (string)$rawValue; - - $this->multiValues[$fieldKey] = $currentValue !== '' - ? array_values(array_filter(explode(',', $currentValue))) - : ['']; - } - } - } - - /** - * Triggered by @close on the modal to discard any typed but unsaved changes - */ - public int $resetIndex = 0; // Add this property - public function resetForm(): void - { - $this->loadData(); - $this->resetIndex++; // Increment to force DOM refresh - } - - public function saveConfiguration() - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - - // final validation layer - $this->validate([ - 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'], - ], [ - 'multiValues.*.*.regex' => 'Items cannot contain commas.', - ]); - - // Prepare config copy to send to db - $finalValues = $this->configuration; - foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { - $fieldKey = $field['keyname']; - - // Handle multi_string: Join array back to CSV string - if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) { - $finalValues[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey]))); - } - - // Handle code fields: Try to JSON decode if necessary (standard TRMNL behavior) - if ($field['field_type'] === 'code' && isset($finalValues[$fieldKey]) && is_string($finalValues[$fieldKey])) { - $decoded = json_decode($finalValues[$fieldKey], true); - if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) { - $finalValues[$fieldKey] = $decoded; - } - } - } - - // send to db - $this->plugin->update(['configuration' => $finalValues]); - $this->configuration = $finalValues; // update local state - $this->dispatch('config-updated'); // notifies listeners - Flux::modal('configuration-modal')->close(); - } - - // ------------------------------------This section contains helper functions for interacting with the form------------------------------------------------ - public function addMultiItem(string $fieldKey): void - { - $this->multiValues[$fieldKey][] = ''; - } - - public function removeMultiItem(string $fieldKey, int $index): void - { - unset($this->multiValues[$fieldKey][$index]); - - $this->multiValues[$fieldKey] = array_values($this->multiValues[$fieldKey]); - - if (empty($this->multiValues[$fieldKey])) { - $this->multiValues[$fieldKey][] = ''; - } - } - - // Livewire magic method to validate MultiValue input boxes - // Runs on every debounce - public function updatedMultiValues($value, $key) - { - $this->validate([ - 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'], - ], [ - 'multiValues.*.*.regex' => 'Items cannot contain commas.', - ]); - } - - public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - - try { - $requestData = []; - if ($query !== null) { - $requestData = [ - 'function' => $fieldKey, - 'query' => $query - ]; - } - - $response = $query !== null - ? Http::post($endpoint, $requestData) - : Http::post($endpoint); - - if ($response->successful()) { - $this->xhrSelectOptions[$fieldKey] = $response->json(); - } else { - $this->xhrSelectOptions[$fieldKey] = []; - } - } catch (\Exception $e) { - $this->xhrSelectOptions[$fieldKey] = []; - } - } - - public function searchXhrSelect(string $fieldKey, string $endpoint): void - { - $query = $this->searchQueries[$fieldKey] ?? ''; - if (!empty($query)) { - $this->loadXhrSelectOptions($fieldKey, $endpoint, $query); - } - } -};?> - - -
-
-
- 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']; - $rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? ''); - - # These are sanitized at Model/Plugin level, safe to render HTML - $safeDescription = $field['description'] ?? ''; - $safeHelp = $field['help_text'] ?? ''; - - // For code fields, if the value is an array, JSON encode it - if ($field['field_type'] === 'code' && is_array($rawValue)) { - $currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } else { - $currentValue = is_array($rawValue) ? '' : (string) $rawValue; - } - @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') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'text') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'code') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'password') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'copyable') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'time_zone') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @foreach(timezone_identifiers_list() as $timezone) - - @endforeach - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'number') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'boolean') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'date') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'time') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'select') - @if(isset($field['multiple']) && $field['multiple'] === true) - - {{ $field['name'] }} - {!! $safeDescription !!} - - @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 - - {!! $safeHelp !!} - - @else - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @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 - - {!! $safeHelp !!} - - @endif - - @elseif($field['field_type'] === 'xhrSelect') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'xhrSelectSearch') -
- - {{ $field['name'] }} - {!! $safeDescription !!} - - - - - {!! $safeHelp !!} - @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - @if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey]))) - {{-- Show current value even if no options are loaded --}} - - @endif - - @endif -
- @elseif($field['field_type'] === 'multi_string') - - {{ $field['name'] }} - {!! $safeDescription !!} - -
- @foreach($multiValues[$fieldKey] as $index => $item) -
- - - - @if(count($multiValues[$fieldKey]) > 1) - - @endif -
- @error("multiValues.{$fieldKey}.{$index}") -
- - {{-- $message comes from thrown error --}} - {{ $message }} -
- @enderror - @endforeach - - - Add Item - -
- {!! $safeHelp !!} -
- @else - Field type "{{ $field['field_type'] }}" not yet supported - @endif -
- @endforeach - @endif - -
- - - Save Configuration - - @if($errors->any()) -
- - - Fix errors before saving. - -
- @endif -
-
-
-
-
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 d902183..9a5dd69 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -19,15 +19,11 @@ new class extends Component { public array $plugins; public $zipFile; - public string $sortBy = 'date_asc'; - public array $native_plugins = [ 'markup' => ['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 = [ @@ -40,55 +36,13 @@ new class extends Component { 'polling_body' => 'nullable|string', ]; - public function refreshPlugins(): void + private 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(); - $allPlugins = array_merge($this->native_plugins, $userPlugins ?? []); - $allPlugins = array_values($allPlugins); - $allPlugins = $this->sortPlugins($allPlugins); - $this->plugins = $allPlugins; - } + $userPlugins = auth()->user()?->plugins?->map(function ($plugin) { + return $plugin->toArray(); + })->toArray(); - protected function sortPlugins(array $plugins): array - { - $pluginsToSort = array_values($plugins); - - switch ($this->sortBy) { - case 'name_asc': - usort($pluginsToSort, function($a, $b) { - return strcasecmp($a['name'] ?? '', $b['name'] ?? ''); - }); - break; - - case 'name_desc': - usort($pluginsToSort, function($a, $b) { - return strcasecmp($b['name'] ?? '', $a['name'] ?? ''); - }); - break; - - case 'date_desc': - usort($pluginsToSort, function($a, $b) { - $aDate = $a['created_at'] ?? '1970-01-01'; - $bDate = $b['created_at'] ?? '1970-01-01'; - return strcmp($bDate, $aDate); - }); - break; - - case 'date_asc': - usort($pluginsToSort, function($a, $b) { - $aDate = $a['created_at'] ?? '1970-01-01'; - $bDate = $b['created_at'] ?? '1970-01-01'; - return strcmp($aDate, $bDate); - }); - break; - } - - return $pluginsToSort; + $this->plugins = array_merge($this->native_plugins, $userPlugins ?? []); } public function mount(): void @@ -96,18 +50,6 @@ new class extends Component { $this->refreshPlugins(); } - public function updatedSortBy(): void - { - $this->refreshPlugins(); - } - - public function getListeners(): array - { - return [ - 'plugin-installed' => 'refreshPlugins', - ]; - } - public function addPlugin(): void { abort_unless(auth()->user() !== null, 403); @@ -135,6 +77,7 @@ new class extends Component { { Artisan::call(ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]); $this->refreshPlugins(); + } @@ -153,85 +96,45 @@ 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->addError('zipFile', 'Error installing plugin: ' . $e->getMessage()); + $this->dispatch('notify', ['type' => 'error', 'message' => 'Error importing plugin: ' . $e->getMessage()]); } } }; ?> -
+

Plugins & Recipes

-
- - - - Add Recipe - - - - - - Import from OSS Catalog - - @if(config('services.trmnl.liquid_enabled')) - - Import from TRMNL Catalog - - @endif - - - Import Recipe Archive - - - Seed Example Recipes - - - -
-
+ + + Add Recipe + + + + + + + Import Recipe + + Seed Example Recipes + + + - -
Import Recipe - Beta + Alpha Upload a ZIP archive containing a TRMNL recipe — either exported from the cloud service or structured using the trmnlp project structure.
@@ -264,7 +167,7 @@ new class extends Component {
- .zip Archive + - @error('zipFile') - - @enderror + @error('zipFile') {{ $message }} @enderror
@@ -285,39 +186,6 @@ new class extends Component {
- -
-
- Import from Catalog - Beta - - Browse and install Recipes from the community. Add yours here. -
- -
-
- - -
-
- Import from TRMNL Recipe Catalog - Alpha - - - Limitations -
    -
  • Only full view will be imported; shared markup will be prepended
  • -
  • Requires trmnl-liquid-cli executable.
  • -
  • API responses in formats other than JSON are not yet fully supported.
  • -
  • There are limitations in payload size (Data Payload, Template).
  • -
- Please report issues, aside from the known limitations, on GitHub. Include the recipe URL. -
-
- -
-
-
@@ -385,26 +253,15 @@ new class extends Component {
- @php - $allPlugins = $this->plugins; - @endphp -
- @foreach($allPlugins as $index => $plugin) + @foreach($plugins as $plugin)
+ class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> -
- @isset($plugin['icon_url']) - - @else - +
+ - @endif

{{$plugin['name']}}

diff --git a/resources/views/livewire/plugins/markup.blade.php b/resources/views/livewire/plugins/markup.blade.php index cb7823e..4cea323 100644 --- a/resources/views/livewire/plugins/markup.blade.php +++ b/resources/views/livewire/plugins/markup.blade.php @@ -70,11 +70,11 @@ new class extends Component { - + TRMNL BYOS Laravel “This screen was rendered by BYOS Laravel” Benjamin Nussbaum - + @@ -88,11 +88,11 @@ HTML; - + Motivational Quote “I love inside jokes. I hope to be a part of one someday.” Michael Scott - + diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 0e29e76..9226af6 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -1,16 +1,12 @@ user()->plugins->contains($this->plugin), 403); $this->blade_code = $this->plugin->render_markup; - // required to render some stuff $this->configuration_template = $this->plugin->configuration_template ?? []; + $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : []; if ($this->plugin->render_markup_view) { try { @@ -71,18 +66,8 @@ new class extends Component { $this->markup_language = $this->plugin->markup_language ?? 'blade'; } - // Initialize screen settings from the model - $this->no_bleed = (bool) ($this->plugin->no_bleed ?? false); - $this->dark_mode = (bool) ($this->plugin->dark_mode ?? false); - $this->fillformFields(); $this->data_payload_updated_at = $this->plugin->data_payload_updated_at; - - // Set default preview device model - if ($this->preview_device_model_id === null) { - $defaultModel = DeviceModel::where('name', 'og_plus')->first() ?? DeviceModel::first(); - $this->preview_device_model_id = $defaultModel?->id; - } } public function fillFormFields(): void @@ -119,13 +104,11 @@ new class extends Component { 'markup_code' => 'nullable|string', 'markup_language' => 'nullable|string|in:blade,liquid', 'checked_devices' => 'array', - 'device_playlist_names' => 'array', - 'device_playlists' => 'array', - 'device_weekdays' => 'array', - 'device_active_from' => 'array', - 'device_active_until' => 'array', - 'no_bleed' => 'boolean', - 'dark_mode' => 'boolean', + 'playlist_name' => 'required_if:selected_playlist,new|string|max:255', + 'selected_weekdays' => 'nullable|array', + 'active_from' => 'nullable|date_format:H:i', + 'active_until' => 'nullable|date_format:H:i', + 'selected_playlist' => 'nullable|string', ]; public function editSettings() @@ -138,19 +121,6 @@ new class extends Component { $validated = $this->validate(); $validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true); $this->plugin->update($validated); - - foreach ($this->configuration_template as $fieldKey => $field) { - if (($field['field_type'] ?? null) !== 'multi_string') { - continue; - } - - if (!isset($this->multiValues[$fieldKey])) { - continue; - } - - $validated[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey]))); - } - } protected function validatePollingUrl(): void @@ -163,7 +133,7 @@ new class extends Component { $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() . $e->getPrevious()?->getMessage()); + $this->addError('polling_url', 'Error resolving Liquid variables: ' . $e->getMessage()); } } } @@ -178,7 +148,7 @@ new class extends Component { $this->data_payload_updated_at = $this->plugin->data_payload_updated_at; } catch (\Exception $e) { - $this->dispatch('data-update-error', message: $e->getMessage() . $e->getPrevious()?->getMessage()); + $this->dispatch('data-update-error', message: $e->getMessage()); } } } @@ -206,40 +176,29 @@ new class extends Component { { $this->validate([ 'checked_devices' => 'required|array|min:1', + 'selected_playlist' => 'required|string', 'mashup_layout' => 'required|string', 'mashup_plugins' => 'required_if:mashup_layout,1Lx1R,1Lx2R,2Lx1R,1Tx1B,2Tx1B,1Tx2B,2x2|array', ]); - // Validate that each checked device has a playlist selected - 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 creating new playlist, validate required fields - 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') { + if ($this->selected_playlist === 'new') { // Create new playlist + $this->validate([ + 'playlist_name' => 'required|string|max:255', + ]); + $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, + 'name' => $this->playlist_name, + 'weekdays' => !empty($this->selected_weekdays) ? $this->selected_weekdays : null, + 'active_from' => $this->active_from ?: null, + 'active_until' => $this->active_until ?: null, ]); } else { - $playlist = \App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]); + $playlist = \App\Models\Playlist::findOrFail($this->selected_playlist); } // Add plugin to playlist @@ -263,39 +222,43 @@ new class extends Component { } } - $this->reset([ - 'checked_devices', - 'device_playlists', - 'device_playlist_names', - 'device_weekdays', - 'device_active_from', - 'device_active_until', - 'mashup_layout', - 'mashup_plugins' - ]); + $this->reset(['checked_devices', 'playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'selected_playlist', 'mashup_layout', 'mashup_plugins']); 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 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 getConfigurationValue($key, $default = null) { return $this->configuration[$key] ?? $default; } + + public function renderExample(string $example) { switch ($example) { @@ -364,17 +327,13 @@ HTML; { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - $this->preview_size = $size; - // 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 { - // Create a device object with og_plus model and the selected bitdepth - $device = $this->createPreviewDevice(); - $previewMarkup = $this->plugin->render($size, true, $device); + $previewMarkup = $this->plugin->render($size); $this->dispatch('preview-updated', preview: $previewMarkup); } catch (LiquidException $e) { $this->dispatch('preview-error', message: $e->toLiquidErrorMessage()); @@ -383,38 +342,6 @@ HTML; } } - private function createPreviewDevice(): \App\Models\Device - { - $deviceModel = DeviceModel::with(['palette'])->find($this->preview_device_model_id) - ?? DeviceModel::with(['palette'])->first(); - - $device = new Device(); - $device->setRelation('deviceModel', $deviceModel); - - return $device; - } - - public function getDeviceModels() - { - return DeviceModel::whereKind('trmnl')->orderBy('label')->get(); - } - - public function updatedPreviewDeviceModelId(): void - { - $this->renderPreview($this->preview_size); - } - - 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); @@ -422,31 +349,42 @@ HTML; $this->redirect(route('plugins.index')); } - #[On('config-updated')] - public function refreshPlugin() - { - // This pulls the fresh 'configuration' from the DB - // and re-triggers the @if check in the Blade template - $this->plugin = $this->plugin->fresh(); - } + public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - // Laravel Livewire computed property: access with $this->parsed_urls - #[Computed] - private function parsedUrls() - { - if (!isset($this->polling_url)) { - return null; + try { + $requestData = []; + if ($query !== null) { + $requestData = [ + 'function' => $fieldKey, + 'query' => $query + ]; + } + + $response = $query !== null + ? Http::post($endpoint, $requestData) + : Http::post($endpoint); + + if ($response->successful()) { + $this->xhrSelectOptions[$fieldKey] = $response->json(); + } else { + $this->xhrSelectOptions[$fieldKey] = []; + } + } catch (\Exception $e) { + $this->xhrSelectOptions[$fieldKey] = []; + } } - try { - return $this->plugin->resolveLiquidVariables($this->polling_url); - - } catch (\Exception $e) { - return 'PARSE_ERROR: ' . $e->getMessage(); + public function searchXhrSelect(string $fieldKey, string $endpoint): void + { + $query = $this->searchQueries[$fieldKey] ?? ''; + if (!empty($query)) { + $this->loadXhrSelectOptions($fieldKey, $endpoint, $query); + } } - } - } + ?>
@@ -478,6 +416,7 @@ HTML; + @@ -487,11 +426,6 @@ HTML; - - Recipe Settings - - - Duplicate Plugin Delete Plugin @@ -516,60 +450,44 @@ HTML;
- @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 + @if(count($checked_devices) === 1) + +
+ + + @foreach($this->getDevicePlaylists($checked_devices[0]) as $playlist) + + @endforeach + +
@endif + @if($selected_playlist) + @if($selected_playlist === 'new') +
+ +
+
+ + + + + + + + + +
+ +
+ +
+ +
+ +
+ @endif - @if(count($checked_devices) > 0 && $this->hasAnyPlaylistSelected())
@@ -633,15 +551,8 @@ HTML; -
+
Preview {{ $plugin->name }} - - - @foreach($this->getDeviceModels() as $model) - - @endforeach - -
@@ -649,9 +560,250 @@ 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'] === 'text') + + @elseif($field['field_type'] === 'code') + + @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'] === 'time') + + @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 + @elseif($field['field_type'] === 'xhrSelect') + + + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) + @if(is_array($option)) + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif + @else + + @endif + @endforeach + @endif + + @elseif($field['field_type'] === 'xhrSelectSearch') +
+ + {{ $field['name'] }} + {{ $field['description'] ?? '' }} + + + + + {{ $field['help_text'] ?? '' }} + @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) + + + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) + @if(is_array($option)) + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif + @else + + @endif + @endforeach + @endif + @if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey]))) + {{-- Show current value even if no options are loaded --}} + + @endif + + @endif +
+ @else + Field type "{{ $field['field_type'] }}" not yet supported + @endif +
+ @endforeach + @endif + +
+ + Save Configuration +
+ +
+

Settings

@@ -739,7 +891,7 @@ HTML; @endif
- Configuration Fields + Configuration
@endif @@ -752,62 +904,15 @@ HTML;
@if($data_strategy === 'polling') - Polling URL - -
-
- - - -
- -
-
- - Enter the URL(s) to poll for data: - + - - {!! 'Hint: Supports multiple requests via line break separation. You can also use configuration variables with Liquid syntax. ' !!} - - -
- -
- - Preview computed URLs here (readonly): - - {{ $this->parsed_urls }} - - -
- - + class="block w-full" type="text" name="polling_url" autofocus> + + Fetch data now
-
@@ -861,22 +966,6 @@ HTML; Enter static JSON data in the Data Payload field. @endif -
- Screen Settings -
- - -
-
-
Save @@ -884,48 +973,14 @@ HTML;
-
- Data Payload - @isset($this->data_payload_updated_at) - {{ $this->data_payload_updated_at?->diffForHumans() ?? 'Never' }} - @endisset -
+ Data Payload + @isset($this->data_payload_updated_at) + {{ $this->data_payload_updated_at?->diffForHumans() ?? 'Never' }} + @endisset - - @php - $textareaId = 'payload-' . uniqid(); - @endphp - - - +
@@ -937,40 +992,15 @@ HTML; {{ $plugin->render_markup_view }} to update.
- - @php - $textareaId = 'code-view-' . uniqid(); - @endphp - - +
@else
@@ -992,41 +1022,15 @@ HTML; @if(!$plugin->render_markup_view)
- - @php - $textareaId = 'code-' . uniqid(); - @endphp - {{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }} - - +
diff --git a/resources/views/livewire/plugins/recipes/settings.blade.php b/resources/views/livewire/plugins/recipes/settings.blade.php deleted file mode 100644 index 8ae3d6f..0000000 --- a/resources/views/livewire/plugins/recipes/settings.blade.php +++ /dev/null @@ -1,104 +0,0 @@ -resetErrorBag(); - // Reload data - $this->plugin = $this->plugin->fresh(); - $this->trmnlp_id = $this->plugin->trmnlp_id; - $this->uuid = $this->plugin->uuid; - $this->alias = $this->plugin->alias ?? false; - } - - public function saveTrmnlpId(): void - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - - $this->validate([ - 'trmnlp_id' => [ - 'nullable', - 'string', - 'max:255', - Rule::unique('plugins', 'trmnlp_id') - ->where('user_id', auth()->id()) - ->ignore($this->plugin->id), - ], - 'alias' => 'boolean', - ]); - - $this->plugin->update([ - 'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id, - 'alias' => $this->alias, - ]); - - Flux::modal('trmnlp-settings')->close(); - } - - public function getAliasUrlProperty(): string - { - return url("/api/display/{$this->uuid}/alias"); - } -};?> - - -
-
- Recipe Settings -
- - -
- {{-- --}} - - TRMNLP Recipe ID - - - Recipe ID in the TRMNL Recipe Catalog. If set, it can be used with trmnlp. - - - - - Enable an Alias URL for this recipe. Your server does not need to be exposed to the internet, but your device must be able to reach the URL. Docs - - - @if($alias) - - Alias URL - - Copy this URL to your TRMNL Dashboard. By default, image is created for TRMNL OG; use parameter ?device-model= to specify a device model. - - @endif -
- -
- - - Cancel - - Save -
- -
-
diff --git a/resources/views/livewire/settings/preferences.blade.php b/resources/views/livewire/settings/preferences.blade.php index cf9dcb7..5c89a96 100644 --- a/resources/views/livewire/settings/preferences.blade.php +++ b/resources/views/livewire/settings/preferences.blade.php @@ -11,12 +11,9 @@ use Livewire\Volt\Component; new class extends Component { public ?int $assign_new_device_id = null; - public ?string $timezone = null; - public function mount(): void { $this->assign_new_device_id = Auth::user()->assign_new_device_id; - $this->timezone = Auth::user()->timezone ?? config('app.timezone'); } public function updatePreferences(): void @@ -29,11 +26,6 @@ new class extends Component { ->whereNull('mirror_device_id'); }), ], - 'timezone' => [ - 'nullable', - 'string', - Rule::in(timezone_identifiers_list()), - ], ]); Auth::user()->update($validated); @@ -47,14 +39,6 @@ new class extends Component {
- - - Select timezone... - @foreach(timezone_identifiers_list() as $tz) - {{ $tz }} - @endforeach - - None @foreach(auth()->user()->devices->where('mirror_device_id', null) as $device) diff --git a/resources/views/recipes/holidays-ical.blade.php b/resources/views/recipes/holidays-ical.blade.php deleted file mode 100644 index 454709d..0000000 --- a/resources/views/recipes/holidays-ical.blade.php +++ /dev/null @@ -1,97 +0,0 @@ -@props(['size' => 'full']) -@php - use Carbon\Carbon; - - $today = Carbon::today(config('app.timezone')); - - $events = collect($data['ical'] ?? []) - ->map(function (array $event): array { - try { - $start = isset($event['DTSTART']) - ? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone')) - : null; - } catch (Exception $e) { - $start = null; - } - - try { - $end = isset($event['DTEND']) - ? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone')) - : null; - } catch (Exception $e) { - $end = null; - } - - return [ - 'summary' => $event['SUMMARY'] ?? 'Untitled event', - 'location' => $event['LOCATION'] ?? '—', - 'start' => $start, - 'end' => $end, - ]; - }) - ->filter(fn ($event) => - $event['start'] && - ( - $event['start']->greaterThanOrEqualTo($today) || - ($event['end'] && $event['end']->greaterThanOrEqualTo($today)) - ) - ) - ->sortBy('start') - ->take($size === 'quadrant' ? 5 : 8) - ->values(); -@endphp - - - - - - - - - Date - - - Time - - - Event - - - Location - - - - - @forelse($events as $event) - - - {{ $event['start']?->format('D, M j') }} - - - - {{ $event['start']?->format('H:i') }} - @if($event['end']) - – {{ $event['end']->format('H:i') }} - @endif - - - - {{ $event['summary'] }} - - - {{ $event['location'] ?? '—' }} - - - @empty - - - No events available - - - @endforelse - - - - - - diff --git a/resources/views/recipes/zen.blade.php b/resources/views/recipes/zen.blade.php index 0ae920f..5e01eac 100644 --- a/resources/views/recipes/zen.blade.php +++ b/resources/views/recipes/zen.blade.php @@ -3,11 +3,11 @@ -
{{$data['data'][0]['a'] ?? ''}}
- @if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant') -

{{ $data['data'][0]['q'] ?? '' }}

+
{{$data[0]['a']}}
+ @if (strlen($data[0]['q']) < 300 && $size != 'quadrant') +

{{ $data[0]['q'] }}

@else -

{{ $data['data'][0]['q'] ?? '' }}

+

{{ $data[0]['q'] }}

@endif
diff --git a/resources/views/trmnl-layouts/mashup.blade.php b/resources/views/trmnl-layouts/mashup.blade.php index 1d8321f..d2890fa 100644 --- a/resources/views/trmnl-layouts/mashup.blade.php +++ b/resources/views/trmnl-layouts/mashup.blade.php @@ -1,25 +1,8 @@ -@props([ - 'mashupLayout' => '1Tx1B', - 'noBleed' => false, - 'darkMode' => false, - 'deviceVariant' => 'og', - 'deviceOrientation' => null, - 'colorDepth' => '1bit', - 'scaleLevel' => null, -]) +@props(['mashupLayout' => '1Tx1B']) -@if(config('app.puppeteer_window_size_strategy') === 'v2') - - - {!! $slot !!} - - -@else - - - {!! $slot !!} - - -@endif + + + {{-- The slot is used to pass the content of the mashup --}} + {!! $slot !!} + + diff --git a/resources/views/trmnl-layouts/single.blade.php b/resources/views/trmnl-layouts/single.blade.php index c6d6499..ec073e5 100644 --- a/resources/views/trmnl-layouts/single.blade.php +++ b/resources/views/trmnl-layouts/single.blade.php @@ -1,20 +1,3 @@ -@props([ - 'noBleed' => false, - 'darkMode' => false, - 'deviceVariant' => 'og', - 'deviceOrientation' => null, - 'colorDepth' => '1bit', - 'scaleLevel' => null, -]) - -@if(config('app.puppeteer_window_size_strategy') === 'v2') - - {!! $slot !!} - -@else - - {!! $slot !!} - -@endif + + {!! $slot !!} + diff --git a/resources/views/trmnl.blade.php b/resources/views/trmnl.blade.php index 9f49685..bcfd3b5 100644 --- a/resources/views/trmnl.blade.php +++ b/resources/views/trmnl.blade.php @@ -1,10 +1,10 @@ - + Motivational Quote “I love inside jokes. I hope to be a part of one someday.” Michael Scott - + diff --git a/resources/views/vendor/trmnl/components/screen.blade.php b/resources/views/vendor/trmnl/components/screen.blade.php index b5e570f..99aa147 100644 --- a/resources/views/vendor/trmnl/components/screen.blade.php +++ b/resources/views/vendor/trmnl/components/screen.blade.php @@ -3,7 +3,7 @@ 'darkMode' => false, 'deviceVariant' => 'og', 'deviceOrientation' => null, - 'colorDepth' => '1bit', + 'colorDepth' => '2bit', 'scaleLevel' => null, ]) diff --git a/routes/api.php b/routes/api.php index d201312..36c7aa0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,16 +18,18 @@ use Illuminate\Support\Str; Route::get('/display', function (Request $request) { $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('api_key', $access_token)->first(); + $device = Device::where('mac_address', $mac_address) + ->where('api_key', $access_token) + ->first(); if (! $device) { // Check if there's a user with assign_new_devices enabled $auto_assign_user = User::where('assign_new_devices', true)->first(); - if ($auto_assign_user && $mac_address) { + if ($auto_assign_user) { // Create a new device and assign it to this user $device = Device::create([ - 'mac_address' => mb_strtoupper($mac_address ?? ''), + 'mac_address' => $mac_address, 'api_key' => $access_token, 'user_id' => $auto_assign_user->id, 'name' => "{$auto_assign_user->name}'s TRMNL", @@ -37,7 +39,7 @@ Route::get('/display', function (Request $request) { ]); } else { return response()->json([ - 'message' => 'MAC Address not registered (or not set), or invalid access token', + 'message' => 'MAC Address not registered or invalid access token', ], 404); } } @@ -58,22 +60,12 @@ Route::get('/display', function (Request $request) { } if ($device->isPauseActive()) { - $image_path = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'sleep'); - if (! $image_path) { - // Generate from template if no device-specific image exists - $image_uuid = ImageGenerationService::generateDefaultScreenImage($device, 'sleep'); - $image_path = 'images/generated/'.$image_uuid.'.png'; - } - $filename = basename($image_path); + $image_path = 'images/sleep.png'; + $filename = 'sleep.png'; $refreshTimeOverride = (int) now()->diffInSeconds($device->pause_until); } elseif ($device->isSleepModeActive()) { - $image_path = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'sleep'); - if (! $image_path) { - // Generate from template if no device-specific image exists - $image_uuid = ImageGenerationService::generateDefaultScreenImage($device, 'sleep'); - $image_path = 'images/generated/'.$image_uuid.'.png'; - } - $filename = basename($image_path); + $image_path = 'images/sleep.png'; + $filename = 'sleep.png'; $refreshTimeOverride = $device->getSleepModeEndsInSeconds() ?? $device->default_refresh_interval; } else { // Get current screen image from a mirror device or continue if not available @@ -93,16 +85,9 @@ Route::get('/display', function (Request $request) { // Check and update stale data if needed if ($plugin->isDataStale() || $plugin->current_image === null) { $plugin->updateDataPayload(); - try { - $markup = $plugin->render(device: $device); + $markup = $plugin->render(); - GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); - } catch (Exception $e) { - Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage()); - // Generate error display - $errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name); - $device->update(['current_screen_image' => $errorImageUuid]); - } + GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); } $plugin->refresh(); @@ -125,17 +110,8 @@ Route::get('/display', function (Request $request) { } } - try { - $markup = $playlistItem->render(device: $device); - GenerateScreenJob::dispatchSync($device->id, null, $markup); - } catch (Exception $e) { - Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage()); - // For mashups, show error for the first plugin or a generic error - $firstPlugin = $plugins->first(); - $pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe'; - $errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName); - $device->update(['current_screen_image' => $errorImageUuid]); - } + $markup = $playlistItem->render(); + GenerateScreenJob::dispatchSync($device->id, null, $markup); $device->refresh(); @@ -149,13 +125,8 @@ Route::get('/display', function (Request $request) { $image_uuid = $device->current_screen_image; } if (! $image_uuid) { - $image_path = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo'); - if (! $image_path) { - // Generate from template if no device-specific image exists - $image_uuid = ImageGenerationService::generateDefaultScreenImage($device, 'setup-logo'); - $image_path = 'images/generated/'.$image_uuid.'.png'; - } - $filename = basename($image_path); + $image_path = 'images/setup-logo.bmp'; + $filename = 'setup-logo.bmp'; } else { // Determine image format based on device settings $preferred_format = 'png'; // Default to PNG for newer firmware @@ -218,7 +189,7 @@ Route::get('/setup', function (Request $request) { ], 404); } - $device = Device::where('mac_address', mb_strtoupper($mac_address))->first(); + $device = Device::where('mac_address', $mac_address)->first(); if (! $device) { // Check if there's a user with assign_new_devices enabled @@ -233,7 +204,7 @@ Route::get('/setup', function (Request $request) { // Create a new device and assign it to this user $device = Device::create([ - 'mac_address' => mb_strtoupper($mac_address), + 'mac_address' => $mac_address, 'api_key' => Str::random(22), 'user_id' => $auto_assign_user->id, 'name' => "{$auto_assign_user->name}'s TRMNL", @@ -254,7 +225,7 @@ Route::get('/setup', function (Request $request) { 'status' => 200, 'api_key' => $device->api_key, 'friendly_id' => $device->friendly_id, - 'image_url' => url('storage/'.ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo')), + 'image_url' => url('storage/images/setup-logo.png'), 'message' => 'Welcome to TRMNL BYOS', ]); }); @@ -359,7 +330,7 @@ Route::post('/display/update', function (Request $request) { Route::post('/screens', function (Request $request) { $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('mac_address', mb_strtoupper($mac_address ?? '')) + $device = Device::where('mac_address', $mac_address) ->where('api_key', $access_token) ->first(); @@ -473,13 +444,8 @@ Route::get('/current_screen', function (Request $request) { $image_uuid = $device->current_screen_image; if (! $image_uuid) { - $image_path = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo'); - if (! $image_path) { - // Generate from template if no device-specific image exists - $image_uuid = ImageGenerationService::generateDefaultScreenImage($device, 'setup-logo'); - $image_path = 'images/generated/'.$image_uuid.'.png'; - } - $filename = basename($image_path); + $image_path = 'images/setup-logo.bmp'; + $filename = 'setup-logo.bmp'; } else { // Determine image format based on device settings $preferred_format = 'png'; // Default to PNG for newer firmware @@ -547,91 +513,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 = 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([ @@ -676,90 +557,3 @@ Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, s ], ]); })->middleware('auth:sanctum'); - -Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) { - $plugin = Plugin::where('uuid', $uuid)->firstOrFail(); - - // Check if alias is active - if (! $plugin->alias) { - return response()->json([ - 'message' => 'Alias is not active for this plugin', - ], 403); - } - - // Get device model name from query parameter, default to 'og_png' - $deviceModelName = $request->query('device-model', 'og_png'); - $deviceModel = DeviceModel::where('name', $deviceModelName)->first(); - - if (! $deviceModel) { - return response()->json([ - 'message' => "Device model '{$deviceModelName}' not found", - ], 404); - } - - // Check if we can use cached image (only for og_png and if data is not stale) - $useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null; - - if ($useCache) { - // Return cached image - $imageUuid = $plugin->current_image; - $fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; - $imagePath = 'images/generated/'.$imageUuid.'.'.$fileExtension; - - // Check if image exists, otherwise fall back to generation - if (Storage::disk('public')->exists($imagePath)) { - return response()->file(Storage::disk('public')->path($imagePath), [ - 'Content-Type' => $deviceModel->mime_type, - ]); - } - } - - // Generate new image - try { - // Update data if needed - if ($plugin->isDataStale()) { - $plugin->updateDataPayload(); - $plugin->refresh(); - } - - // Load device model with palette relationship - $deviceModel->load('palette'); - - // Create a virtual device for rendering (Plugin::render needs a Device object) - $virtualDevice = new Device(); - $virtualDevice->setRelation('deviceModel', $deviceModel); - $virtualDevice->setRelation('user', $plugin->user); - $virtualDevice->setRelation('palette', $deviceModel->palette); - - // Render the plugin markup - $markup = $plugin->render(device: $virtualDevice); - - // Generate image using the new method that doesn't require a device - $imageUuid = ImageGenerationService::generateImageFromModel( - markup: $markup, - deviceModel: $deviceModel, - user: $plugin->user, - palette: $deviceModel->palette - ); - - // Update plugin cache if using og_png - if ($deviceModelName === 'og_png') { - $plugin->update(['current_image' => $imageUuid]); - } - - // Return the generated image - $fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; - $imagePath = Storage::disk('public')->path('images/generated/'.$imageUuid.'.'.$fileExtension); - - return response()->file($imagePath, [ - 'Content-Type' => $deviceModel->mime_type, - ]); - } catch (Exception $e) { - Log::error("Failed to generate alias image for plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage()); - - return response()->json([ - 'message' => 'Failed to generate image', - 'error' => $e->getMessage(), - ], 500); - } -})->name('api.display.alias'); diff --git a/routes/web.php b/routes/web.php index b3069bd..e6afc1a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -24,15 +24,12 @@ Route::middleware(['auth'])->group(function () { Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs'); Volt::route('/device-models', 'device-models.index')->name('device-models.index'); - Volt::route('/device-palettes', 'device-palettes.index')->name('device-palettes.index'); Volt::route('plugins', 'plugins.index')->name('plugins.index'); 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/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/storage/app/public/firmwares/.gitignore b/storage/app/public/.gitignore similarity index 60% rename from storage/app/public/firmwares/.gitignore rename to storage/app/public/.gitignore index d6b7ef3..19a4b22 100644 --- a/storage/app/public/firmwares/.gitignore +++ b/storage/app/public/.gitignore @@ -1,2 +1,3 @@ * +!images/ !.gitignore diff --git a/storage/app/public/images/default-screens/.gitkeep b/storage/app/public/images/default-screens/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/storage/app/public/images/default-screens/setup-logo_1024_768_8_90.png b/storage/app/public/images/default-screens/setup-logo_1024_768_8_90.png deleted file mode 100644 index 3734da1..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_1024_768_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/setup-logo_1200_820_3_0.png b/storage/app/public/images/default-screens/setup-logo_1200_820_3_0.png deleted file mode 100644 index 17dcf60..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_1200_820_3_0.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/setup-logo_1400_840_8_90.png b/storage/app/public/images/default-screens/setup-logo_1400_840_8_90.png deleted file mode 100644 index 71ecd65..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_1400_840_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/setup-logo_1440_1080_4_90.png b/storage/app/public/images/default-screens/setup-logo_1440_1080_4_90.png deleted file mode 100644 index a350061..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_1440_1080_4_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/setup-logo_1448_1072_8_90.png b/storage/app/public/images/default-screens/setup-logo_1448_1072_8_90.png deleted file mode 100644 index ec22fc8..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_1448_1072_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/setup-logo_1600_1200_1_0.png b/storage/app/public/images/default-screens/setup-logo_1600_1200_1_0.png deleted file mode 100644 index f080990..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_1600_1200_1_0.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/setup-logo_1680_1264_8_90.png b/storage/app/public/images/default-screens/setup-logo_1680_1264_8_90.png deleted file mode 100644 index c3099d5..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_1680_1264_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/setup-logo_1872_1404_8_90.png b/storage/app/public/images/default-screens/setup-logo_1872_1404_8_90.png deleted file mode 100644 index 5894ab8..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_1872_1404_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/setup-logo_800_480_1_0.png b/storage/app/public/images/default-screens/setup-logo_800_480_1_0.png deleted file mode 100644 index 1b0d150..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_800_480_1_0.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/setup-logo_800_480_2_0.png b/storage/app/public/images/default-screens/setup-logo_800_480_2_0.png deleted file mode 100644 index 031e369..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_800_480_2_0.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/setup-logo_800_600_8_90.png b/storage/app/public/images/default-screens/setup-logo_800_600_8_90.png deleted file mode 100644 index 61061e7..0000000 Binary files a/storage/app/public/images/default-screens/setup-logo_800_600_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/sleep_1024_768_8_90.png b/storage/app/public/images/default-screens/sleep_1024_768_8_90.png deleted file mode 100644 index f8763f3..0000000 Binary files a/storage/app/public/images/default-screens/sleep_1024_768_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/sleep_1200_820_3_0.png b/storage/app/public/images/default-screens/sleep_1200_820_3_0.png deleted file mode 100644 index 287ec0a..0000000 Binary files a/storage/app/public/images/default-screens/sleep_1200_820_3_0.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/sleep_1400_840_8_90.png b/storage/app/public/images/default-screens/sleep_1400_840_8_90.png deleted file mode 100644 index 2f2166e..0000000 Binary files a/storage/app/public/images/default-screens/sleep_1400_840_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/sleep_1440_1080_4_90.png b/storage/app/public/images/default-screens/sleep_1440_1080_4_90.png deleted file mode 100644 index 5846cad..0000000 Binary files a/storage/app/public/images/default-screens/sleep_1440_1080_4_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/sleep_1448_1072_8_90.png b/storage/app/public/images/default-screens/sleep_1448_1072_8_90.png deleted file mode 100644 index a08c008..0000000 Binary files a/storage/app/public/images/default-screens/sleep_1448_1072_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/sleep_1600_1200_1_0.png b/storage/app/public/images/default-screens/sleep_1600_1200_1_0.png deleted file mode 100644 index 060da4e..0000000 Binary files a/storage/app/public/images/default-screens/sleep_1600_1200_1_0.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/sleep_1680_1264_8_90.png b/storage/app/public/images/default-screens/sleep_1680_1264_8_90.png deleted file mode 100644 index 6509a44..0000000 Binary files a/storage/app/public/images/default-screens/sleep_1680_1264_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/sleep_1872_1404_8_90.png b/storage/app/public/images/default-screens/sleep_1872_1404_8_90.png deleted file mode 100644 index 792d964..0000000 Binary files a/storage/app/public/images/default-screens/sleep_1872_1404_8_90.png and /dev/null differ diff --git a/storage/app/public/images/default-screens/sleep_800_600_8_90.png b/storage/app/public/images/default-screens/sleep_800_600_8_90.png deleted file mode 100644 index f066974..0000000 Binary files a/storage/app/public/images/default-screens/sleep_800_600_8_90.png and /dev/null differ diff --git a/storage/app/public/images/setup-logo.png b/storage/app/public/images/setup-logo.png deleted file mode 100644 index 1fc342c..0000000 Binary files a/storage/app/public/images/setup-logo.png and /dev/null differ diff --git a/storage/app/public/images/sleep.bmp b/storage/app/public/images/sleep.bmp deleted file mode 100644 index a55af63..0000000 Binary files a/storage/app/public/images/sleep.bmp and /dev/null differ diff --git a/storage/app/public/images/sleep.png b/storage/app/public/images/sleep.png deleted file mode 100644 index 49bdabf..0000000 Binary files a/storage/app/public/images/sleep.png and /dev/null differ diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index c98cb2f..b971c81 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -7,21 +7,18 @@ use App\Models\Playlist; use App\Models\PlaylistItem; use App\Models\Plugin; use App\Models\User; -use App\Services\ImageGenerationService; -use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\Sanctum; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -beforeEach(function (): void { - TrmnlPipeline::fake(); +beforeEach(function () { Storage::fake('public'); Storage::disk('public')->makeDirectory('/images/generated'); }); -test('device can fetch display data with valid credentials', function (): void { +test('device can fetch display data with valid credentials', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -53,7 +50,7 @@ test('device can fetch display data with valid credentials', function (): void { ->last_firmware_version->toBe('1.0.0'); }); -test('display endpoint includes image_url_timeout when configured', function (): void { +test('display endpoint includes image_url_timeout when configured', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -75,7 +72,7 @@ test('display endpoint includes image_url_timeout when configured', function (): ]); }); -test('display endpoint omits image_url_timeout when not configured', function (): void { +test('display endpoint omits image_url_timeout when not configured', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -95,7 +92,7 @@ test('display endpoint omits image_url_timeout when not configured', function () ->assertJsonMissing(['image_url_timeout']); }); -test('new device is auto-assigned to user with auto-assign enabled', function (): void { +test('new device is auto-assigned to user with auto-assign enabled', function () { $user = User::factory()->create(['assign_new_devices' => true]); $response = $this->withHeaders([ @@ -115,7 +112,7 @@ test('new device is auto-assigned to user with auto-assign enabled', function () ->api_key->toBe('new-device-key'); }); -test('new device is auto-assigned and mirrors specified device', function (): void { +test('new device is auto-assigned and mirrors specified device', function () { // Create a source device that will be mirrored $sourceDevice = Device::factory()->create([ 'mac_address' => 'AA:BB:CC:DD:EE:FF', @@ -154,7 +151,7 @@ test('new device is auto-assigned and mirrors specified device', function (): vo ]); }); -test('device setup endpoint returns correct data', function (): void { +test('device setup endpoint returns correct data', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -173,7 +170,7 @@ test('device setup endpoint returns correct data', function (): void { ]); }); -test('device can submit logs', function (): void { +test('device can submit logs', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -201,7 +198,7 @@ test('device can submit logs', function (): void { expect($device->logs()->count())->toBe(1); }); -test('device can submit logs in revised format', function (): void { +test('device can submit logs in revised format', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -241,7 +238,7 @@ test('device can submit logs in revised format', function (): void { // $response->assertOk(); // }); -test('user cannot update display for devices they do not own', function (): void { +test('user cannot update display for devices they do not own', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); $device = Device::factory()->create(['user_id' => $otherUser->id]); @@ -256,17 +253,17 @@ test('user cannot update display for devices they do not own', function (): void $response->assertForbidden(); }); -test('invalid device credentials return error', function (): void { +test('invalid device credentials return error', function () { $response = $this->withHeaders([ 'id' => 'invalid-mac', 'access-token' => 'invalid-token', ])->get('/api/display'); $response->assertNotFound() - ->assertJson(['message' => 'MAC Address not registered (or not set), or invalid access token']); + ->assertJson(['message' => 'MAC Address not registered or invalid access token']); }); -test('log endpoint requires valid device credentials', function (): void { +test('log endpoint requires valid device credentials', function () { $response = $this->withHeaders([ 'id' => 'invalid-mac', 'access-token' => 'invalid-token', @@ -276,7 +273,7 @@ test('log endpoint requires valid device credentials', function (): void { ->assertJson(['message' => 'Device not found or invalid access token']); }); -test('update_firmware flag is only returned once', function (): void { +test('update_firmware flag is only returned once', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -321,7 +318,7 @@ test('update_firmware flag is only returned once', function (): void { expect($device->proxy_cloud_response['update_firmware'])->toBeFalse(); }); -test('authenticated user can fetch device status', function (): void { +test('authenticated user can fetch device status', function () { $user = User::factory()->create(); $device = Device::factory()->create([ 'user_id' => $user->id, @@ -355,7 +352,7 @@ test('authenticated user can fetch device status', function (): void { ]); }); -test('user cannot fetch status for devices they do not own', function (): void { +test('user cannot fetch status for devices they do not own', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); $device = Device::factory()->create(['user_id' => $otherUser->id]); @@ -367,7 +364,7 @@ test('user cannot fetch status for devices they do not own', function (): void { $response->assertForbidden(); }); -test('display status endpoint requires device_id parameter', function (): void { +test('display status endpoint requires device_id parameter', function () { $user = User::factory()->create(); Sanctum::actingAs($user); @@ -377,7 +374,7 @@ test('display status endpoint requires device_id parameter', function (): void { ->assertJsonValidationErrors(['device_id']); }); -test('display status endpoint requires valid device_id', function (): void { +test('display status endpoint requires valid device_id', function () { $user = User::factory()->create(); Sanctum::actingAs($user); @@ -387,7 +384,7 @@ test('display status endpoint requires valid device_id', function (): void { ->assertJsonValidationErrors(['device_id']); }); -test('device can mirror another device', function (): void { +test('device can mirror another device', function () { // Create source device with a playlist and image $sourceDevice = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', @@ -429,7 +426,7 @@ test('device can mirror another device', function (): void { ->last_firmware_version->toBe('1.0.0'); }); -test('device can fetch current screen data', function (): void { +test('device can fetch current screen data', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -452,7 +449,7 @@ test('device can fetch current screen data', function (): void { ]); }); -test('current_screen endpoint requires valid device credentials', function (): void { +test('current_screen endpoint requires valid device credentials', function () { $response = $this->withHeaders([ 'access-token' => 'invalid-token', ])->get('/api/current_screen'); @@ -461,7 +458,7 @@ test('current_screen endpoint requires valid device credentials', function (): v ->assertJson(['message' => 'Device not found or invalid access token']); }); -test('authenticated user can fetch their devices', function (): void { +test('authenticated user can fetch their devices', function () { $user = User::factory()->create(); $devices = Device::factory()->count(2)->create([ 'user_id' => $user->id, @@ -503,7 +500,7 @@ test('authenticated user can fetch their devices', function (): void { ]); }); -test('plugin caches image until data is stale', function (): void { +test('plugin caches image until data is stale', function () { // Create source device with a playlist $device = Device::factory()->create([ 'mac_address' => '55:11:22:33:44:55', @@ -576,9 +573,9 @@ test('plugin caches image until data is stale', function (): void { expect($thirdResponse['filename']) ->not->toBe($firstResponse['filename']); -}); +})->skipOnCi(); -test('plugins in playlist are rendered in order', function (): void { +test('plugins in playlist are rendered in order', function () { // Create source device with a playlist $device = Device::factory()->create([ 'mac_address' => '55:11:22:33:44:55', @@ -680,9 +677,9 @@ test('plugins in playlist are rendered in order', function (): void { $thirdResponse->assertOk(); expect($thirdResponse['filename']) ->not->toBe($secondResponse['filename']); -}); +})->skipOnCi(); -test('display endpoint updates last_refreshed_at timestamp', function (): void { +test('display endpoint updates last_refreshed_at timestamp', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -703,7 +700,7 @@ test('display endpoint updates last_refreshed_at timestamp', function (): void { ->and($device->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2); }); -test('display endpoint updates last_refreshed_at timestamp for mirrored devices', function (): void { +test('display endpoint updates last_refreshed_at timestamp for mirrored devices', function () { // Create source device $sourceDevice = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', @@ -732,7 +729,7 @@ test('display endpoint updates last_refreshed_at timestamp for mirrored devices' ->and($mirrorDevice->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2); }); -test('display endpoint handles mashup playlist items correctly', function (): void { +test('display endpoint handles mashup playlist items correctly', function () { // Create a device $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', @@ -790,9 +787,9 @@ test('display endpoint handles mashup playlist items correctly', function (): vo // Verify the playlist item was marked as displayed $playlistItem->refresh(); expect($playlistItem->last_displayed_at)->not->toBeNull(); -}); +})->skipOnCi(); -test('device in sleep mode returns sleep image and correct refresh rate', function (): void { +test('device in sleep mode returns sleep image and correct refresh rate', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -813,16 +810,16 @@ test('device in sleep mode returns sleep image and correct refresh rate', functi 'fw-version' => '1.0.0', ])->get('/api/display'); - $response->assertOk(); - - // The filename should be a UUID-based PNG file since we're generating from template - expect($response['filename'])->toMatch('/^[a-f0-9-]+\.png$/'); + $response->assertOk() + ->assertJson([ + 'filename' => 'sleep.png', + ]); expect($response['refresh_rate'])->toBeGreaterThan(0); Carbon\Carbon::setTestNow(); // Clear test time }); -test('device not in sleep mode returns normal image', function (): void { +test('device not in sleep mode returns normal image', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -851,7 +848,7 @@ test('device not in sleep mode returns normal image', function (): void { Carbon\Carbon::setTestNow(); // Clear test time }); -test('device returns sleep.png and correct refresh time when paused', function (): void { +test('device returns sleep.png and correct refresh time when paused', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -868,14 +865,12 @@ test('device returns sleep.png and correct refresh time when paused', function ( $response->assertOk(); $json = $response->json(); - - // The filename should be a UUID-based PNG file since we're generating from template - expect($json['filename'])->toMatch('/^[a-f0-9-]+\.png$/'); - expect($json['image_url'])->toContain('images/generated/'); + expect($json['filename'])->toBe('sleep.png'); + expect($json['image_url'])->toContain('sleep.png'); expect($json['refresh_rate'])->toBeLessThanOrEqual(3600); // ~60 min }); -test('screens endpoint accepts nullable file_name', function (): void { +test('screens endpoint accepts nullable file_name', function () { Queue::fake(); $device = Device::factory()->create([ @@ -897,7 +892,7 @@ test('screens endpoint accepts nullable file_name', function (): void { Queue::assertPushed(GenerateScreenJob::class); }); -test('screens endpoint returns 404 for invalid device credentials', function (): void { +test('screens endpoint returns 404 for invalid device credentials', function () { $response = $this->withHeaders([ 'id' => 'invalid-mac', 'access-token' => 'invalid-key', @@ -914,7 +909,7 @@ test('screens endpoint returns 404 for invalid device credentials', function (): ]); }); -test('setup endpoint assigns device model when model-id header is provided', function (): void { +test('setup endpoint assigns device model when model-id header is provided', function () { $user = User::factory()->create(['assign_new_devices' => true]); $deviceModel = DeviceModel::factory()->create([ 'name' => 'test-model', @@ -937,7 +932,7 @@ test('setup endpoint assigns device model when model-id header is provided', fun ->and($device->device_model_id)->toBe($deviceModel->id); }); -test('setup endpoint handles non-existent device model gracefully', function (): void { +test('setup endpoint handles non-existent device model gracefully', function () { $user = User::factory()->create(['assign_new_devices' => true]); $response = $this->withHeaders([ @@ -955,232 +950,3 @@ test('setup endpoint handles non-existent device model gracefully', function (): expect($device)->not->toBeNull() ->and($device->device_model_id)->toBeNull(); }); - -test('setup endpoint matches MAC address case-insensitively', function (): void { - // Create device with lowercase MAC address - $device = Device::factory()->create([ - 'mac_address' => 'a1:b2:c3:d4:e5:f6', - 'api_key' => 'test-api-key', - 'friendly_id' => 'test-device', - ]); - - // Request with uppercase MAC address should still match - $response = $this->withHeaders([ - 'id' => 'A1:B2:C3:D4:E5:F6', - ])->get('/api/setup'); - - $response->assertOk() - ->assertJson([ - 'status' => 200, - 'api_key' => 'test-api-key', - 'friendly_id' => 'test-device', - 'message' => 'Welcome to TRMNL BYOS', - ]); -}); - -test('display endpoint matches MAC address case-insensitively', function (): void { - // Create device with lowercase MAC address - $device = Device::factory()->create([ - 'mac_address' => 'a1:b2:c3:d4:e5:f6', - 'api_key' => 'test-api-key', - 'current_screen_image' => 'test-image', - ]); - - // Request with uppercase MAC address should still match - $response = $this->withHeaders([ - 'id' => 'A1:B2:C3:D4:E5:F6', - 'access-token' => $device->api_key, - 'rssi' => -70, - 'battery_voltage' => 3.8, - 'fw-version' => '1.0.0', - ])->get('/api/display'); - - $response->assertOk() - ->assertJson([ - 'status' => '0', - 'filename' => 'test-image.bmp', - ]); -}); - -test('screens endpoint matches MAC address case-insensitively', function (): void { - Queue::fake(); - - // Create device with uppercase MAC address - $device = Device::factory()->create([ - 'mac_address' => 'A1:B2:C3:D4:E5:F6', - 'api_key' => 'test-api-key', - ]); - - // Request with lowercase MAC address should still match - $response = $this->withHeaders([ - 'id' => 'a1:b2:c3:d4:e5:f6', - 'access-token' => $device->api_key, - ])->post('/api/screens', [ - 'image' => [ - 'content' => '
Test content
', - ], - ]); - - $response->assertOk(); - Queue::assertPushed(GenerateScreenJob::class); -}); - -test('display endpoint handles plugin rendering errors gracefully', function (): void { - TrmnlPipeline::fake(); - - $device = Device::factory()->create([ - 'mac_address' => '00:11:22:33:44:55', - 'api_key' => 'test-api-key', - 'proxy_cloud' => false, - ]); - - // Create a plugin with Blade markup that will cause an exception when accessing data[0] - // when data is not an array or doesn't have index 0 - $plugin = Plugin::factory()->create([ - 'name' => 'Broken Recipe', - 'data_strategy' => 'polling', - 'polling_url' => null, - 'data_stale_minutes' => 1, - 'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access - 'render_markup' => '
{{ $data[0]["invalid"] }}
', // This will fail if data[0] doesn't exist - 'data_payload' => ['error' => 'Failed to fetch data'], // Not a list, so data[0] will fail - 'data_payload_updated_at' => now()->subMinutes(2), // Make it stale - 'current_image' => null, - ]); - - $playlist = Playlist::factory()->create([ - 'device_id' => $device->id, - 'name' => 'test_playlist', - 'is_active' => true, - 'weekdays' => null, - 'active_from' => null, - 'active_until' => null, - ]); - - PlaylistItem::factory()->create([ - 'playlist_id' => $playlist->id, - 'plugin_id' => $plugin->id, - 'order' => 1, - 'is_active' => true, - 'last_displayed_at' => null, - ]); - - $response = $this->withHeaders([ - 'id' => $device->mac_address, - 'access-token' => $device->api_key, - 'rssi' => -70, - 'battery_voltage' => 3.8, - 'fw-version' => '1.0.0', - ])->get('/api/display'); - - $response->assertOk(); - - // Verify error screen was generated and set on device - $device->refresh(); - expect($device->current_screen_image)->not->toBeNull(); - - // Verify the error image exists - $errorImagePath = Storage::disk('public')->path("images/generated/{$device->current_screen_image}.png"); - // The TrmnlPipeline is faked, so we just verify the UUID was set - expect($device->current_screen_image)->toBeString(); -}); - -test('display endpoint handles mashup rendering errors gracefully', function (): void { - TrmnlPipeline::fake(); - - $device = Device::factory()->create([ - 'mac_address' => '00:11:22:33:44:55', - 'api_key' => 'test-api-key', - 'proxy_cloud' => false, - ]); - - // Create plugins for mashup, one with invalid markup - $plugin1 = Plugin::factory()->create([ - 'name' => 'Working Plugin', - 'data_strategy' => 'polling', - 'polling_url' => null, - 'data_stale_minutes' => 1, - 'render_markup_view' => 'trmnl', - 'data_payload_updated_at' => now()->subMinutes(2), - 'current_image' => null, - ]); - - $plugin2 = Plugin::factory()->create([ - 'name' => 'Broken Plugin', - 'data_strategy' => 'polling', - 'polling_url' => null, - 'data_stale_minutes' => 1, - 'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access - 'render_markup' => '
{{ $data[0]["invalid"] }}
', // This will fail - 'data_payload' => ['error' => 'Failed to fetch data'], - 'data_payload_updated_at' => now()->subMinutes(2), - 'current_image' => null, - ]); - - $playlist = Playlist::factory()->create([ - 'device_id' => $device->id, - 'name' => 'test_playlist', - 'is_active' => true, - 'weekdays' => null, - 'active_from' => null, - 'active_until' => null, - ]); - - // Create mashup playlist item - $playlistItem = PlaylistItem::createMashup( - $playlist, - '1Lx1R', - [$plugin1->id, $plugin2->id], - 'Test Mashup', - 1 - ); - - $response = $this->withHeaders([ - 'id' => $device->mac_address, - 'access-token' => $device->api_key, - 'rssi' => -70, - 'battery_voltage' => 3.8, - 'fw-version' => '1.0.0', - ])->get('/api/display'); - - $response->assertOk(); - - // Verify error screen was generated and set on device - $device->refresh(); - expect($device->current_screen_image)->not->toBeNull(); - - // Verify the error image UUID was set - expect($device->current_screen_image)->toBeString(); -}); - -test('generateDefaultScreenImage creates error screen with plugin name', function (): void { - TrmnlPipeline::fake(); - Storage::fake('public'); - Storage::disk('public')->makeDirectory('/images/generated'); - - $device = Device::factory()->create(); - - $errorUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', 'Test Recipe Name'); - - expect($errorUuid)->not->toBeEmpty(); - - // Verify the error image path would be created - $errorPath = "images/generated/{$errorUuid}.png"; - // Since TrmnlPipeline is faked, we just verify the UUID was generated - expect($errorUuid)->toBeString(); -}); - -test('generateDefaultScreenImage throws exception for invalid error image type', function (): void { - $device = Device::factory()->create(); - - expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-error-type')) - ->toThrow(InvalidArgumentException::class); -}); - -test('getDeviceSpecificDefaultImage returns null for error type when no device-specific image exists', function (): void { - $device = new Device(); - $device->deviceModel = null; - - $result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'error'); - expect($result)->toBeNull(); -}); diff --git a/tests/Feature/Api/DeviceImageFormatTest.php b/tests/Feature/Api/DeviceImageFormatTest.php index a7db928..fcb7555 100644 --- a/tests/Feature/Api/DeviceImageFormatTest.php +++ b/tests/Feature/Api/DeviceImageFormatTest.php @@ -10,12 +10,12 @@ use Illuminate\Support\Facades\Storage; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -beforeEach(function (): void { +beforeEach(function () { Storage::fake('public'); Storage::disk('public')->makeDirectory('/images/generated'); }); -test('device with firmware version 1.5.1 gets bmp format', function (): void { +test('device with firmware version 1.5.1 gets bmp format', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -52,7 +52,7 @@ test('device with firmware version 1.5.1 gets bmp format', function (): void { ]); }); -test('device with firmware version 1.5.2 gets png format', function (): void { +test('device with firmware version 1.5.2 gets png format', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -88,7 +88,7 @@ test('device with firmware version 1.5.2 gets png format', function (): void { ]); }); -test('device falls back to bmp when png does not exist', function (): void { +test('device falls back to bmp when png does not exist', function () { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -124,7 +124,7 @@ test('device falls back to bmp when png does not exist', function (): void { ]); }); -test('device without device_model_id and image_format bmp3_1bit_srgb returns bmp when plugin is rendered', function (): void { +test('device without device_model_id and image_format bmp3_1bit_srgb returns bmp when plugin is rendered', function () { // Create a user with auto-assign enabled $user = User::factory()->create([ 'assign_new_devices' => true, 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/Feature/Api/PluginSettingsArchiveTest.php b/tests/Feature/Api/PluginSettingsArchiveTest.php index f0ad3d0..517f2f8 100644 --- a/tests/Feature/Api/PluginSettingsArchiveTest.php +++ b/tests/Feature/Api/PluginSettingsArchiveTest.php @@ -8,7 +8,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; -it('accepts a plugin settings archive and updates the plugin', function (): void { +it('accepts a plugin settings archive and updates the plugin', function () { $user = User::factory()->create(); $plugin = Plugin::factory()->create([ 'user_id' => $user->id, diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 07c1683..96edffc 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -5,13 +5,13 @@ use Livewire\Volt\Volt as LivewireVolt; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('login screen can be rendered', function (): void { +test('login screen can be rendered', function () { $response = $this->get('/login'); $response->assertStatus(200); }); -test('users can authenticate using the login screen', function (): void { +test('users can authenticate using the login screen', function () { $user = User::factory()->create(); $response = LivewireVolt::test('auth.login') @@ -26,7 +26,7 @@ test('users can authenticate using the login screen', function (): void { $this->assertAuthenticated(); }); -test('users can not authenticate with invalid password', function (): void { +test('users can not authenticate with invalid password', function () { $user = User::factory()->create(); $this->post('/login', [ @@ -37,7 +37,7 @@ test('users can not authenticate with invalid password', function (): void { $this->assertGuest(); }); -test('users can logout', function (): void { +test('users can logout', function () { $user = User::factory()->create(); $response = $this->actingAs($user)->post('/logout'); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 5cc2db8..52a663d 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -7,7 +7,7 @@ use Illuminate\Support\Facades\URL; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('email verification screen can be rendered', function (): void { +test('email verification screen can be rendered', function () { $user = User::factory()->unverified()->create(); $response = $this->actingAs($user)->get('/verify-email'); @@ -15,7 +15,7 @@ test('email verification screen can be rendered', function (): void { $response->assertStatus(200); }); -test('email can be verified', function (): void { +test('email can be verified', function () { $user = User::factory()->unverified()->create(); Event::fake(); @@ -23,7 +23,7 @@ test('email can be verified', function (): void { $verificationUrl = URL::temporarySignedRoute( 'verification.verify', now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1((string) $user->email)] + ['id' => $user->id, 'hash' => sha1($user->email)] ); $response = $this->actingAs($user)->get($verificationUrl); @@ -34,7 +34,7 @@ test('email can be verified', function (): void { $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); }); -test('email is not verified with invalid hash', function (): void { +test('email is not verified with invalid hash', function () { $user = User::factory()->unverified()->create(); $verificationUrl = URL::temporarySignedRoute( diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index 265963a..efb11ce 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -5,7 +5,7 @@ use Livewire\Volt\Volt; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('confirm password screen can be rendered', function (): void { +test('confirm password screen can be rendered', function () { $user = User::factory()->create(); $response = $this->actingAs($user)->get('/confirm-password'); @@ -13,7 +13,7 @@ test('confirm password screen can be rendered', function (): void { $response->assertStatus(200); }); -test('password can be confirmed', function (): void { +test('password can be confirmed', function () { $user = User::factory()->create(); $this->actingAs($user); @@ -27,7 +27,7 @@ test('password can be confirmed', function (): void { ->assertRedirect(route('dashboard', absolute: false)); }); -test('password is not confirmed with invalid password', function (): void { +test('password is not confirmed with invalid password', function () { $user = User::factory()->create(); $this->actingAs($user); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index 2f38263..86fda9d 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -7,13 +7,13 @@ use Livewire\Volt\Volt; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('reset password link screen can be rendered', function (): void { +test('reset password link screen can be rendered', function () { $response = $this->get('/forgot-password'); $response->assertStatus(200); }); -test('reset password link can be requested', function (): void { +test('reset password link can be requested', function () { Notification::fake(); $user = User::factory()->create(); @@ -25,7 +25,7 @@ test('reset password link can be requested', function (): void { Notification::assertSentTo($user, ResetPassword::class); }); -test('reset password screen can be rendered', function (): void { +test('reset password screen can be rendered', function () { Notification::fake(); $user = User::factory()->create(); @@ -34,7 +34,7 @@ test('reset password screen can be rendered', function (): void { ->set('email', $user->email) ->call('sendPasswordResetLink'); - Notification::assertSentTo($user, ResetPassword::class, function ($notification): true { + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { $response = $this->get('/reset-password/'.$notification->token); $response->assertStatus(200); @@ -43,7 +43,7 @@ test('reset password screen can be rendered', function (): void { }); }); -test('password can be reset with valid token', function (): void { +test('password can be reset with valid token', function () { Notification::fake(); $user = User::factory()->create(); @@ -52,7 +52,7 @@ test('password can be reset with valid token', function (): void { ->set('email', $user->email) ->call('sendPasswordResetLink'); - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user): true { + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { $response = Volt::test('auth.reset-password', ['token' => $notification->token]) ->set('email', $user->email) ->set('password', 'password') diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 45bc39b..a1c4c07 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -4,13 +4,13 @@ use Livewire\Volt\Volt; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('registration screen can be rendered', function (): void { +test('registration screen can be rendered', function () { $response = $this->get('/register'); $response->assertStatus(200); }); -test('new users can register', function (): void { +test('new users can register', function () { $response = Volt::test('auth.register') ->set('name', 'Test User') ->set('email', 'test@example.com') diff --git a/tests/Feature/Console/ExampleRecipesSeederCommandTest.php b/tests/Feature/Console/ExampleRecipesSeederCommandTest.php deleted file mode 100644 index 74241e0..0000000 --- a/tests/Feature/Console/ExampleRecipesSeederCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -shouldReceive('run') - ->once() - ->with('123'); - - $this->app->instance(ExampleRecipesSeeder::class, $seeder); - - $this->artisan('recipes:seed', ['user_id' => '123']) - ->assertExitCode(0); -}); - -test('example recipes seeder command has correct signature', function (): void { - $command = $this->app->make(App\Console\Commands\ExampleRecipesSeederCommand::class); - - expect($command->getName())->toBe('recipes:seed'); - expect($command->getDescription())->toBe('Seed example recipes'); -}); - -test('example recipes seeder command prompts for missing input', function (): void { - $seeder = Mockery::mock(ExampleRecipesSeeder::class); - $seeder->shouldReceive('run') - ->once() - ->with('456'); - - $this->app->instance(ExampleRecipesSeeder::class, $seeder); - - $this->artisan('recipes:seed') - ->expectsQuestion('What is the user_id?', '456') - ->assertExitCode(0); -}); diff --git a/tests/Feature/Console/FetchProxyCloudResponsesCommandTest.php b/tests/Feature/Console/FetchProxyCloudResponsesCommandTest.php index e8d12f0..b34357d 100644 --- a/tests/Feature/Console/FetchProxyCloudResponsesCommandTest.php +++ b/tests/Feature/Console/FetchProxyCloudResponsesCommandTest.php @@ -3,7 +3,7 @@ use App\Jobs\FetchProxyCloudResponses; use Illuminate\Support\Facades\Bus; -test('it dispatches fetch proxy cloud responses job', function (): void { +test('it dispatches fetch proxy cloud responses job', function () { // Prevent the job from actually running Bus::fake(); diff --git a/tests/Feature/Console/FirmwareCheckCommandTest.php b/tests/Feature/Console/FirmwareCheckCommandTest.php deleted file mode 100644 index 459a035..0000000 --- a/tests/Feature/Console/FirmwareCheckCommandTest.php +++ /dev/null @@ -1,72 +0,0 @@ -app->make(App\Console\Commands\FirmwareCheckCommand::class); - - expect($command->getName())->toBe('trmnl:firmware:check'); - expect($command->getDescription())->toBe('Checks for the latest firmware and downloads it if flag --download is passed.'); -}); - -test('firmware check command runs without errors', function (): void { - // Mock the firmware API response - Http::fake([ - 'https://usetrmnl.com/api/firmware/latest' => Http::response([ - 'version' => '1.0.0', - 'url' => 'https://example.com/firmware.bin', - ], 200), - ]); - - $this->artisan('trmnl:firmware:check') - ->assertExitCode(0); - - // Verify that the firmware was created - expect(App\Models\Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue(); -}); - -test('firmware check command runs with download flag', function (): void { - // Mock the firmware API response - Http::fake([ - 'https://usetrmnl.com/api/firmware/latest' => Http::response([ - 'version' => '1.0.0', - 'url' => 'https://example.com/firmware.bin', - ], 200), - 'https://example.com/firmware.bin' => Http::response('fake firmware content', 200), - ]); - - // Mock storage to prevent actual file operations - Storage::fake('public'); - - $this->artisan('trmnl:firmware:check', ['--download' => true]) - ->assertExitCode(0); - - // Verify that the firmware was created and marked as latest - expect(App\Models\Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue(); - - // Verify that the firmware was downloaded (storage_location should be set) - $firmware = App\Models\Firmware::where('version_tag', '1.0.0')->first(); - expect($firmware->storage_location)->toBe('firmwares/FW1.0.0.bin'); -}); - -test('firmware check command can run successfully', function (): void { - // Mock the firmware API response - Http::fake([ - 'https://usetrmnl.com/api/firmware/latest' => Http::response([ - 'version' => '1.0.0', - 'url' => 'https://example.com/firmware.bin', - ], 200), - ]); - - $this->artisan('trmnl:firmware:check') - ->assertExitCode(0); - - // Verify that the firmware was created - expect(App\Models\Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue(); -}); diff --git a/tests/Feature/Console/FirmwareUpdateCommandTest.php b/tests/Feature/Console/FirmwareUpdateCommandTest.php deleted file mode 100644 index 3e8c916..0000000 --- a/tests/Feature/Console/FirmwareUpdateCommandTest.php +++ /dev/null @@ -1,86 +0,0 @@ -artisan('trmnl:firmware:update --help') - ->assertExitCode(0); -}); - -test('firmware update command can be called', function (): void { - $user = User::factory()->create(); - $device = Device::factory()->create(['user_id' => $user->id]); - $firmware = Firmware::factory()->create(['version_tag' => '1.0.0']); - - $this->artisan('trmnl:firmware:update') - ->expectsQuestion('Check for new firmware?', 'no') - ->expectsQuestion('Update to which version?', $firmware->id) - ->expectsQuestion('Which devices should be updated?', ["_$device->id"]) - ->assertExitCode(0); - - $device->refresh(); - expect($device->update_firmware_id)->toBe($firmware->id); -}); - -test('firmware update command updates all devices when all is selected', function (): void { - $user = User::factory()->create(); - $device1 = Device::factory()->create(['user_id' => $user->id]); - $device2 = Device::factory()->create(['user_id' => $user->id]); - $firmware = Firmware::factory()->create(['version_tag' => '1.0.0']); - - $this->artisan('trmnl:firmware:update') - ->expectsQuestion('Check for new firmware?', 'no') - ->expectsQuestion('Update to which version?', $firmware->id) - ->expectsQuestion('Which devices should be updated?', ['all']) - ->assertExitCode(0); - - $device1->refresh(); - $device2->refresh(); - expect($device1->update_firmware_id)->toBe($firmware->id); - expect($device2->update_firmware_id)->toBe($firmware->id); -}); - -test('firmware update command aborts when no devices selected', function (): void { - $firmware = Firmware::factory()->create(['version_tag' => '1.0.0']); - - $this->artisan('trmnl:firmware:update') - ->expectsQuestion('Check for new firmware?', 'no') - ->expectsQuestion('Update to which version?', $firmware->id) - ->expectsQuestion('Which devices should be updated?', []) - ->expectsOutput('No devices selected. Aborting.') - ->assertExitCode(0); -}); - -test('firmware update command calls firmware check when check is selected', function (): void { - $user = User::factory()->create(); - $device = Device::factory()->create(['user_id' => $user->id]); - $firmware = Firmware::factory()->create(['version_tag' => '1.0.0']); - - $this->artisan('trmnl:firmware:update') - ->expectsQuestion('Check for new firmware?', 'check') - ->expectsQuestion('Update to which version?', $firmware->id) - ->expectsQuestion('Which devices should be updated?', ["_$device->id"]) - ->assertExitCode(0); - - $device->refresh(); - expect($device->update_firmware_id)->toBe($firmware->id); -}); - -test('firmware update command calls firmware check with download when download is selected', function (): void { - $user = User::factory()->create(); - $device = Device::factory()->create(['user_id' => $user->id]); - $firmware = Firmware::factory()->create(['version_tag' => '1.0.0']); - - $this->artisan('trmnl:firmware:update') - ->expectsQuestion('Check for new firmware?', 'download') - ->expectsQuestion('Update to which version?', $firmware->id) - ->expectsQuestion('Which devices should be updated?', ["_$device->id"]) - ->assertExitCode(0); - - $device->refresh(); - expect($device->update_firmware_id)->toBe($firmware->id); -}); diff --git a/tests/Feature/Console/MashupCreateCommandTest.php b/tests/Feature/Console/MashupCreateCommandTest.php deleted file mode 100644 index e2d35eb..0000000 --- a/tests/Feature/Console/MashupCreateCommandTest.php +++ /dev/null @@ -1,154 +0,0 @@ -artisan('mashup:create --help') - ->assertExitCode(0); -}); - -test('mashup create command creates mashup successfully', function (): void { - $user = User::factory()->create(); - $device = Device::factory()->create(['user_id' => $user->id]); - $playlist = Playlist::factory()->create(['device_id' => $device->id]); - $plugin1 = Plugin::factory()->create(['user_id' => $user->id]); - $plugin2 = Plugin::factory()->create(['user_id' => $user->id]); - - $this->artisan('mashup:create') - ->expectsQuestion('Select a device', $device->id) - ->expectsQuestion('Select a playlist', $playlist->id) - ->expectsQuestion('Select a layout', '1Lx1R') - ->expectsQuestion('Enter a name for this mashup', 'Test Mashup') - ->expectsQuestion('Select the first plugin', $plugin1->id) - ->expectsQuestion('Select the second plugin', $plugin2->id) - ->expectsOutput('Mashup created successfully!') - ->assertExitCode(0); - - $playlistItem = PlaylistItem::where('playlist_id', $playlist->id) - ->whereJsonContains('mashup->mashup_name', 'Test Mashup') - ->first(); - - expect($playlistItem)->not->toBeNull(); - expect($playlistItem->isMashup())->toBeTrue(); - expect($playlistItem->getMashupLayoutType())->toBe('1Lx1R'); - expect($playlistItem->getMashupPluginIds())->toContain($plugin1->id, $plugin2->id); -}); - -test('mashup create command exits when no devices found', function (): void { - $this->artisan('mashup:create') - ->expectsOutput('No devices found. Please create a device first.') - ->assertExitCode(1); -}); - -test('mashup create command exits when no playlists found for device', function (): void { - $user = User::factory()->create(); - $device = Device::factory()->create(['user_id' => $user->id]); - - $this->artisan('mashup:create') - ->expectsQuestion('Select a device', $device->id) - ->expectsOutput('No playlists found for this device. Please create a playlist first.') - ->assertExitCode(1); -}); - -test('mashup create command exits when no plugins found', function (): void { - $user = User::factory()->create(); - $device = Device::factory()->create(['user_id' => $user->id]); - $playlist = Playlist::factory()->create(['device_id' => $device->id]); - - $this->artisan('mashup:create') - ->expectsQuestion('Select a device', $device->id) - ->expectsQuestion('Select a playlist', $playlist->id) - ->expectsQuestion('Select a layout', '1Lx1R') - ->expectsQuestion('Enter a name for this mashup', 'Test Mashup') - ->expectsOutput('No plugins found. Please create some plugins first.') - ->assertExitCode(1); -}); - -test('mashup create command validates mashup name length', function (): void { - $user = User::factory()->create(); - $device = Device::factory()->create(['user_id' => $user->id]); - $playlist = Playlist::factory()->create(['device_id' => $device->id]); - $plugin1 = Plugin::factory()->create(['user_id' => $user->id]); - $plugin2 = Plugin::factory()->create(['user_id' => $user->id]); - - $this->artisan('mashup:create') - ->expectsQuestion('Select a device', $device->id) - ->expectsQuestion('Select a playlist', $playlist->id) - ->expectsQuestion('Select a layout', '1Lx1R') - ->expectsQuestion('Enter a name for this mashup', 'A') // Too short - ->expectsOutput('The name must be at least 2 characters.') - ->assertExitCode(1); -}); - -test('mashup create command validates mashup name maximum length', function (): void { - $user = User::factory()->create(); - $device = Device::factory()->create(['user_id' => $user->id]); - $playlist = Playlist::factory()->create(['device_id' => $device->id]); - $plugin1 = Plugin::factory()->create(['user_id' => $user->id]); - $plugin2 = Plugin::factory()->create(['user_id' => $user->id]); - - $longName = str_repeat('A', 51); // Too long - - $this->artisan('mashup:create') - ->expectsQuestion('Select a device', $device->id) - ->expectsQuestion('Select a playlist', $playlist->id) - ->expectsQuestion('Select a layout', '1Lx1R') - ->expectsQuestion('Enter a name for this mashup', $longName) - ->expectsOutput('The name must not exceed 50 characters.') - ->assertExitCode(1); -}); - -test('mashup create command uses default name when provided', function (): void { - $user = User::factory()->create(); - $device = Device::factory()->create(['user_id' => $user->id]); - $playlist = Playlist::factory()->create(['device_id' => $device->id]); - $plugin1 = Plugin::factory()->create(['user_id' => $user->id]); - $plugin2 = Plugin::factory()->create(['user_id' => $user->id]); - - $this->artisan('mashup:create') - ->expectsQuestion('Select a device', $device->id) - ->expectsQuestion('Select a playlist', $playlist->id) - ->expectsQuestion('Select a layout', '1Lx1R') - ->expectsQuestion('Enter a name for this mashup', 'Mashup') // Default value - ->expectsQuestion('Select the first plugin', $plugin1->id) - ->expectsQuestion('Select the second plugin', $plugin2->id) - ->expectsOutput('Mashup created successfully!') - ->assertExitCode(0); - - $playlistItem = PlaylistItem::where('playlist_id', $playlist->id) - ->whereJsonContains('mashup->mashup_name', 'Mashup') - ->first(); - - expect($playlistItem)->not->toBeNull(); -}); - -test('mashup create command handles 1x1 layout with single plugin', function (): void { - $user = User::factory()->create(); - $device = Device::factory()->create(['user_id' => $user->id]); - $playlist = Playlist::factory()->create(['device_id' => $device->id]); - $plugin = Plugin::factory()->create(['user_id' => $user->id]); - - $this->artisan('mashup:create') - ->expectsQuestion('Select a device', $device->id) - ->expectsQuestion('Select a playlist', $playlist->id) - ->expectsQuestion('Select a layout', '1x1') - ->expectsQuestion('Enter a name for this mashup', 'Single Plugin Mashup') - ->expectsQuestion('Select the first plugin', $plugin->id) - ->expectsOutput('Mashup created successfully!') - ->assertExitCode(0); - - $playlistItem = PlaylistItem::where('playlist_id', $playlist->id) - ->whereJsonContains('mashup->mashup_name', 'Single Plugin Mashup') - ->first(); - - expect($playlistItem)->not->toBeNull(); - expect($playlistItem->getMashupLayoutType())->toBe('1x1'); - expect($playlistItem->getMashupPluginIds())->toHaveCount(1); - expect($playlistItem->getMashupPluginIds())->toContain($plugin->id); -}); diff --git a/tests/Feature/Console/OidcTestCommandTest.php b/tests/Feature/Console/OidcTestCommandTest.php deleted file mode 100644 index 56ccea8..0000000 --- a/tests/Feature/Console/OidcTestCommandTest.php +++ /dev/null @@ -1,197 +0,0 @@ -artisan('oidc:test --help') - ->assertExitCode(0); -}); - -test('oidc test command runs successfully with disabled oidc', function (): void { - config([ - 'app.url' => 'http://localhost', - 'services.oidc.enabled' => false, - 'services.oidc.endpoint' => null, - 'services.oidc.client_id' => null, - 'services.oidc.client_secret' => null, - 'services.oidc.redirect' => null, - 'services.oidc.scopes' => [], - ]); - - $this->artisan('oidc:test') - ->expectsOutput('Testing OIDC Configuration...') - ->expectsOutput('OIDC Enabled: ❌ No') - ->expectsOutput('OIDC Endpoint: ❌ Not set') - ->expectsOutput('Client ID: ❌ Not set') - ->expectsOutput('Client Secret: ❌ Not set') - ->expectsOutput('Redirect URL: ✅ http://localhost/auth/oidc/callback') - ->expectsOutput('Scopes: ✅ openid, profile, email') - ->expectsOutput('OIDC Driver: ✅ Registered (configuration test skipped due to missing values)') - ->expectsOutput('⚠️ OIDC driver is registered but missing required configuration.') - ->expectsOutput('Please set the following environment variables:') - ->expectsOutput(' - OIDC_ENABLED=true') - ->expectsOutput(' - OIDC_ENDPOINT=https://your-oidc-provider.com (base URL)') - ->expectsOutput(' OR') - ->expectsOutput(' - OIDC_ENDPOINT=https://your-oidc-provider.com/.well-known/openid-configuration (full URL)') - ->expectsOutput(' - OIDC_CLIENT_ID=your-client-id') - ->expectsOutput(' - OIDC_CLIENT_SECRET=your-client-secret') - ->assertExitCode(0); -}); - -test('oidc test command runs successfully with enabled oidc but missing config', function (): void { - config([ - 'app.url' => 'http://localhost', - 'services.oidc.enabled' => true, - 'services.oidc.endpoint' => null, - 'services.oidc.client_id' => null, - 'services.oidc.client_secret' => null, - 'services.oidc.redirect' => null, - 'services.oidc.scopes' => [], - ]); - - $this->artisan('oidc:test') - ->expectsOutput('Testing OIDC Configuration...') - ->expectsOutput('OIDC Enabled: ✅ Yes') - ->expectsOutput('OIDC Endpoint: ❌ Not set') - ->expectsOutput('Client ID: ❌ Not set') - ->expectsOutput('Client Secret: ❌ Not set') - ->expectsOutput('Redirect URL: ✅ http://localhost/auth/oidc/callback') - ->expectsOutput('Scopes: ✅ openid, profile, email') - ->expectsOutput('OIDC Driver: ✅ Registered (configuration test skipped due to missing values)') - ->expectsOutput('⚠️ OIDC driver is registered but missing required configuration.') - ->expectsOutput('Please set the following environment variables:') - ->expectsOutput(' - OIDC_ENDPOINT=https://your-oidc-provider.com (base URL)') - ->expectsOutput(' OR') - ->expectsOutput(' - OIDC_ENDPOINT=https://your-oidc-provider.com/.well-known/openid-configuration (full URL)') - ->expectsOutput(' - OIDC_CLIENT_ID=your-client-id') - ->expectsOutput(' - OIDC_CLIENT_SECRET=your-client-secret') - ->assertExitCode(0); -}); - -test('oidc test command runs successfully with partial config', function (): void { - config([ - 'services.oidc.enabled' => true, - 'services.oidc.endpoint' => 'https://example.com', - 'services.oidc.client_id' => 'test-client-id', - 'services.oidc.client_secret' => null, - 'services.oidc.redirect' => 'https://example.com/callback', - 'services.oidc.scopes' => ['openid', 'profile'], - ]); - - $this->artisan('oidc:test') - ->expectsOutput('Testing OIDC Configuration...') - ->expectsOutput('OIDC Enabled: ✅ Yes') - ->expectsOutput('OIDC Endpoint: ✅ https://example.com') - ->expectsOutput('Client ID: ✅ test-client-id') - ->expectsOutput('Client Secret: ❌ Not set') - ->expectsOutput('Redirect URL: ✅ https://example.com/callback') - ->expectsOutput('Scopes: ✅ openid, profile') - ->expectsOutput('OIDC Driver: ✅ Registered (configuration test skipped due to missing values)') - ->expectsOutput('⚠️ OIDC driver is registered but missing required configuration.') - ->expectsOutput('Please set the following environment variables:') - ->expectsOutput(' - OIDC_CLIENT_SECRET=your-client-secret') - ->assertExitCode(0); -}); - -test('oidc test command runs successfully with full config but disabled', function (): void { - // Mock the HTTP client to return fake OIDC configuration - mock(GuzzleHttp\Client::class, function ($mock): void { - $mock->shouldReceive('get') - ->with('https://example.com/.well-known/openid-configuration') - ->andReturn(new GuzzleHttp\Psr7\Response(200, [], json_encode([ - 'authorization_endpoint' => 'https://example.com/auth', - 'token_endpoint' => 'https://example.com/token', - 'userinfo_endpoint' => 'https://example.com/userinfo', - ]))); - }); - - config([ - 'services.oidc.enabled' => false, - 'services.oidc.endpoint' => 'https://example.com', - 'services.oidc.client_id' => 'test-client-id', - 'services.oidc.client_secret' => 'test-client-secret', - 'services.oidc.redirect' => 'https://example.com/callback', - 'services.oidc.scopes' => ['openid', 'profile'], - ]); - - $this->artisan('oidc:test') - ->expectsOutput('Testing OIDC Configuration...') - ->expectsOutput('OIDC Enabled: ❌ No') - ->expectsOutput('OIDC Endpoint: ✅ https://example.com') - ->expectsOutput('Client ID: ✅ test-client-id') - ->expectsOutput('Client Secret: ✅ Set') - ->expectsOutput('Redirect URL: ✅ https://example.com/callback') - ->expectsOutput('Scopes: ✅ openid, profile') - ->expectsOutput('OIDC Driver: ✅ Successfully registered and accessible') - ->expectsOutput('⚠️ OIDC driver is working but OIDC_ENABLED is false.') - ->assertExitCode(0); -}); - -test('oidc test command runs successfully with full config and enabled', function (): void { - // Mock the HTTP client to return fake OIDC configuration - mock(GuzzleHttp\Client::class, function ($mock): void { - $mock->shouldReceive('get') - ->with('https://example.com/.well-known/openid-configuration') - ->andReturn(new GuzzleHttp\Psr7\Response(200, [], json_encode([ - 'authorization_endpoint' => 'https://example.com/auth', - 'token_endpoint' => 'https://example.com/token', - 'userinfo_endpoint' => 'https://example.com/userinfo', - ]))); - }); - - config([ - 'services.oidc.enabled' => true, - 'services.oidc.endpoint' => 'https://example.com', - 'services.oidc.client_id' => 'test-client-id', - 'services.oidc.client_secret' => 'test-client-secret', - 'services.oidc.redirect' => 'https://example.com/callback', - 'services.oidc.scopes' => ['openid', 'profile'], - ]); - - $this->artisan('oidc:test') - ->expectsOutput('Testing OIDC Configuration...') - ->expectsOutput('OIDC Enabled: ✅ Yes') - ->expectsOutput('OIDC Endpoint: ✅ https://example.com') - ->expectsOutput('Client ID: ✅ test-client-id') - ->expectsOutput('Client Secret: ✅ Set') - ->expectsOutput('Redirect URL: ✅ https://example.com/callback') - ->expectsOutput('Scopes: ✅ openid, profile') - ->expectsOutput('OIDC Driver: ✅ Successfully registered and accessible') - ->expectsOutput('✅ OIDC is fully configured and ready to use!') - ->expectsOutput('You can test the login flow at: /auth/oidc/redirect') - ->assertExitCode(0); -}); - -test('oidc test command handles empty scopes', function (): void { - // Mock the HTTP client to return fake OIDC configuration - mock(GuzzleHttp\Client::class, function ($mock): void { - $mock->shouldReceive('get') - ->with('https://example.com/.well-known/openid-configuration') - ->andReturn(new GuzzleHttp\Psr7\Response(200, [], json_encode([ - 'authorization_endpoint' => 'https://example.com/auth', - 'token_endpoint' => 'https://example.com/token', - 'userinfo_endpoint' => 'https://example.com/userinfo', - ]))); - }); - - config([ - 'services.oidc.enabled' => false, - 'services.oidc.endpoint' => 'https://example.com', - 'services.oidc.client_id' => 'test-client-id', - 'services.oidc.client_secret' => 'test-client-secret', - 'services.oidc.redirect' => 'https://example.com/callback', - 'services.oidc.scopes' => null, - ]); - - $this->artisan('oidc:test') - ->expectsOutput('Testing OIDC Configuration...') - ->expectsOutput('OIDC Enabled: ❌ No') - ->expectsOutput('OIDC Endpoint: ✅ https://example.com') - ->expectsOutput('Client ID: ✅ test-client-id') - ->expectsOutput('Client Secret: ✅ Set') - ->expectsOutput('Redirect URL: ✅ https://example.com/callback') - ->expectsOutput('Scopes: ✅ openid, profile, email') - ->assertExitCode(0); -}); diff --git a/tests/Feature/Console/ScreenGeneratorCommandTest.php b/tests/Feature/Console/ScreenGeneratorCommandTest.php index 1f18107..54621d6 100644 --- a/tests/Feature/Console/ScreenGeneratorCommandTest.php +++ b/tests/Feature/Console/ScreenGeneratorCommandTest.php @@ -3,7 +3,7 @@ use App\Jobs\GenerateScreenJob; use Illuminate\Support\Facades\Bus; -test('it generates screen with default parameters', function (): void { +test('it generates screen with default parameters', function () { Bus::fake(); $this->artisan('trmnl:screen:generate') diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index 110adc8..4ed5100 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -4,12 +4,12 @@ use App\Models\User; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('guests are redirected to the login page', function (): void { +test('guests are redirected to the login page', function () { $response = $this->get('/dashboard'); $response->assertRedirect('/login'); }); -test('authenticated users can visit the dashboard', function (): void { +test('authenticated users can visit the dashboard', function () { $user = User::factory()->create(); $this->actingAs($user); diff --git a/tests/Feature/Devices/DeviceConfigureTest.php b/tests/Feature/Devices/DeviceConfigureTest.php index dff0954..85b1fd3 100644 --- a/tests/Feature/Devices/DeviceConfigureTest.php +++ b/tests/Feature/Devices/DeviceConfigureTest.php @@ -10,7 +10,7 @@ use function Pest\Laravel\actingAs; uses(RefreshDatabase::class); -test('configure view displays last_refreshed_at timestamp', function (): void { +test('configure view displays last_refreshed_at timestamp', function () { $user = User::factory()->create(); $device = Device::factory()->create([ 'user_id' => $user->id, diff --git a/tests/Feature/Devices/DeviceRotationTest.php b/tests/Feature/Devices/DeviceRotationTest.php index 35367ba..e6fb7e0 100644 --- a/tests/Feature/Devices/DeviceRotationTest.php +++ b/tests/Feature/Devices/DeviceRotationTest.php @@ -8,7 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -test('dashboard shows device image with correct rotation', function (): void { +test('dashboard shows device image with correct rotation', function () { $user = User::factory()->create(); $device = Device::factory()->create([ 'user_id' => $user->id, @@ -28,7 +28,7 @@ test('dashboard shows device image with correct rotation', function (): void { $response->assertSee('origin-center'); }); -test('device configure page shows device image with correct rotation', function (): void { +test('device configure page shows device image with correct rotation', function () { $user = User::factory()->create(); $device = Device::factory()->create([ 'user_id' => $user->id, @@ -48,7 +48,7 @@ test('device configure page shows device image with correct rotation', function $response->assertSee('origin-center'); }); -test('device with no rotation shows no transform style', function (): void { +test('device with no rotation shows no transform style', function () { $user = User::factory()->create(); $device = Device::factory()->create([ 'user_id' => $user->id, @@ -67,7 +67,7 @@ test('device with no rotation shows no transform style', function (): void { $response->assertSee('-rotate-[0deg]'); }); -test('device with null rotation defaults to 0', function (): void { +test('device with null rotation defaults to 0', function () { $user = User::factory()->create(); $device = Device::factory()->create([ 'user_id' => $user->id, diff --git a/tests/Feature/Devices/DeviceTest.php b/tests/Feature/Devices/DeviceTest.php index 3cff76b..e03a82a 100644 --- a/tests/Feature/Devices/DeviceTest.php +++ b/tests/Feature/Devices/DeviceTest.php @@ -5,7 +5,7 @@ use Illuminate\Support\Carbon; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('device can be created with basic attributes', function (): void { +test('device can be created with basic attributes', function () { $device = Device::factory()->create([ 'name' => 'Test Device', ]); @@ -14,7 +14,7 @@ test('device can be created with basic attributes', function (): void { ->and($device->name)->toBe('Test Device'); }); -test('battery percentage is calculated correctly', function (): void { +test('battery percentage is calculated correctly', function () { $cases = [ ['voltage' => 3.0, 'expected' => 0], // Min voltage ['voltage' => 4.2, 'expected' => 100], // Max voltage @@ -34,7 +34,7 @@ test('battery percentage is calculated correctly', function (): void { } }); -test('wifi strength is determined correctly', function (): void { +test('wifi strength is determined correctly', function () { $cases = [ ['rssi' => 0, 'expected' => 0], // No signal ['rssi' => -90, 'expected' => 1], // Weak signal @@ -52,7 +52,7 @@ test('wifi strength is determined correctly', function (): void { } }); -test('proxy cloud attribute is properly cast to boolean', function (): void { +test('proxy cloud attribute is properly cast to boolean', function () { $device = Device::factory()->create([ 'proxy_cloud' => true, ]); @@ -63,7 +63,7 @@ test('proxy cloud attribute is properly cast to boolean', function (): void { expect($device->proxy_cloud)->toBeFalse(); }); -test('last log request is properly cast to json', function (): void { +test('last log request is properly cast to json', function () { $logData = ['status' => 'success', 'timestamp' => '2024-03-04 12:00:00']; $device = Device::factory()->create([ @@ -76,7 +76,7 @@ test('last log request is properly cast to json', function (): void { ->toHaveKey('timestamp'); }); -test('getSleepModeEndsInSeconds returns correct value for overnight sleep window', function (): void { +test('getSleepModeEndsInSeconds returns correct value for overnight sleep window', function () { // Set the current time to 12:13 Carbon::setTestNow(Carbon::create(2024, 1, 1, 12, 13, 0)); diff --git a/tests/Feature/Devices/ManageTest.php b/tests/Feature/Devices/ManageTest.php index fbfd2f2..a629cfe 100644 --- a/tests/Feature/Devices/ManageTest.php +++ b/tests/Feature/Devices/ManageTest.php @@ -6,7 +6,7 @@ use Livewire\Volt\Volt; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('device management page can be rendered', function (): void { +test('device management page can be rendered', function () { $user = User::factory()->create(); $response = $this->actingAs($user) @@ -15,7 +15,7 @@ test('device management page can be rendered', function (): void { $response->assertOk(); }); -test('user can create a new device', function (): void { +test('user can create a new device', function () { $user = User::factory()->create(); $this->actingAs($user); @@ -48,7 +48,7 @@ test('user can create a new device', function (): void { expect($device->user_id)->toBe($user->id); }); -test('device creation requires required fields', function (): void { +test('device creation requires required fields', function () { $user = User::factory()->create(); $this->actingAs($user); @@ -67,7 +67,7 @@ test('device creation requires required fields', function (): void { ]); }); -test('user can toggle proxy cloud for their device', function (): void { +test('user can toggle proxy cloud for their device', function () { $user = User::factory()->create(); $this->actingAs($user); $device = Device::factory()->create([ @@ -88,7 +88,7 @@ test('user can toggle proxy cloud for their device', function (): void { expect($device->fresh()->proxy_cloud)->toBeFalse(); }); -test('user cannot toggle proxy cloud for other users devices', function (): void { +test('user cannot toggle proxy cloud for other users devices', function () { $user = User::factory()->create(); $this->actingAs($user); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 34782b1..8b5843f 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,6 +1,6 @@ get('/'); $response->assertStatus(200); diff --git a/tests/Feature/FetchDeviceModelsCommandTest.php b/tests/Feature/FetchDeviceModelsCommandTest.php index e09ff4c..2836330 100644 --- a/tests/Feature/FetchDeviceModelsCommandTest.php +++ b/tests/Feature/FetchDeviceModelsCommandTest.php @@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Queue; uses(RefreshDatabase::class); -test('command dispatches fetch device models job', function (): void { +test('command dispatches fetch device models job', function () { Queue::fake(); $this->artisan('device-models:fetch') diff --git a/tests/Feature/FetchProxyCloudResponsesTest.php b/tests/Feature/FetchProxyCloudResponsesTest.php index 561dc1c..bd58002 100644 --- a/tests/Feature/FetchProxyCloudResponsesTest.php +++ b/tests/Feature/FetchProxyCloudResponsesTest.php @@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Storage; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -beforeEach(function (): void { +beforeEach(function () { Storage::fake('public'); Storage::disk('public')->makeDirectory('/images/generated'); Http::preventStrayRequests(); @@ -18,7 +18,7 @@ beforeEach(function (): void { ]); }); -test('it fetches and processes proxy cloud responses for devices', function (): void { +test('it fetches and processes proxy cloud responses for devices', function () { config(['services.trmnl.proxy_base_url' => 'https://example.com']); // Create a test device with proxy cloud enabled @@ -59,14 +59,16 @@ test('it fetches and processes proxy cloud responses for devices', function (): $job->handle(); // Assert HTTP requests were made with correct headers - Http::assertSent(fn ($request): bool => $request->hasHeader('id', $device->mac_address) && - $request->hasHeader('access-token', $device->api_key) && - $request->hasHeader('width', 800) && - $request->hasHeader('height', 480) && - $request->hasHeader('rssi', $device->last_rssi_level) && - $request->hasHeader('battery_voltage', $device->last_battery_voltage) && - $request->hasHeader('refresh-rate', $device->default_refresh_interval) && - $request->hasHeader('fw-version', $device->last_firmware_version)); + Http::assertSent(function ($request) use ($device) { + return $request->hasHeader('id', $device->mac_address) && + $request->hasHeader('access-token', $device->api_key) && + $request->hasHeader('width', 800) && + $request->hasHeader('height', 480) && + $request->hasHeader('rssi', $device->last_rssi_level) && + $request->hasHeader('battery_voltage', $device->last_battery_voltage) && + $request->hasHeader('refresh-rate', $device->default_refresh_interval) && + $request->hasHeader('fw-version', $device->last_firmware_version); + }); // Assert the device was updated $device->refresh(); @@ -80,7 +82,7 @@ test('it fetches and processes proxy cloud responses for devices', function (): Storage::disk('public')->assertExists('images/generated/test-image.bmp'); }); -test('it handles log requests when present', function (): void { +test('it handles log requests when present', function () { $device = Device::factory()->create([ 'proxy_cloud' => true, 'mac_address' => '00:11:22:33:44:55', @@ -101,16 +103,18 @@ test('it handles log requests when present', function (): void { $job->handle(); // Assert log request was sent - Http::assertSent(fn ($request): bool => $request->url() === config('services.trmnl.proxy_base_url').'/api/log' && - $request->hasHeader('id', $device->mac_address) && - $request->body() === json_encode(['message' => 'test log'])); + Http::assertSent(function ($request) use ($device) { + return $request->url() === config('services.trmnl.proxy_base_url').'/api/log' && + $request->hasHeader('id', $device->mac_address) && + $request->body() === json_encode(['message' => 'test log']); + }); // Assert log request was cleared $device->refresh(); expect($device->last_log_request)->toBeNull(); }); -test('it handles API errors gracefully', function (): void { +test('it handles API errors gracefully', function () { $device = Device::factory()->create([ 'proxy_cloud' => true, 'mac_address' => '00:11:22:33:44:55', @@ -126,7 +130,7 @@ test('it handles API errors gracefully', function (): void { expect(fn () => $job->handle())->not->toThrow(Exception::class); }); -test('it only processes proxy cloud enabled devices', function (): void { +test('it only processes proxy cloud enabled devices', function () { Http::fake(); $enabledDevice = Device::factory()->create(['proxy_cloud' => true]); $disabledDevice = Device::factory()->create(['proxy_cloud' => false]); @@ -135,12 +139,16 @@ test('it only processes proxy cloud enabled devices', function (): void { $job->handle(); // Assert request was only made for enabled device - Http::assertSent(fn ($request) => $request->hasHeader('id', $enabledDevice->mac_address)); + Http::assertSent(function ($request) use ($enabledDevice) { + return $request->hasHeader('id', $enabledDevice->mac_address); + }); - Http::assertNotSent(fn ($request) => $request->hasHeader('id', $disabledDevice->mac_address)); + Http::assertNotSent(function ($request) use ($disabledDevice) { + return $request->hasHeader('id', $disabledDevice->mac_address); + }); }); -test('it fetches and processes proxy cloud responses for devices with BMP images', function (): void { +test('it fetches and processes proxy cloud responses for devices with BMP images', function () { config(['services.trmnl.proxy_base_url' => 'https://example.com']); // Create a test device with proxy cloud enabled @@ -168,14 +176,16 @@ test('it fetches and processes proxy cloud responses for devices with BMP images $job->handle(); // Assert HTTP requests were made with correct headers - Http::assertSent(fn ($request): bool => $request->hasHeader('id', $device->mac_address) && - $request->hasHeader('access-token', $device->api_key) && - $request->hasHeader('width', 800) && - $request->hasHeader('height', 480) && - $request->hasHeader('rssi', $device->last_rssi_level) && - $request->hasHeader('battery_voltage', $device->last_battery_voltage) && - $request->hasHeader('refresh-rate', $device->default_refresh_interval) && - $request->hasHeader('fw-version', $device->last_firmware_version)); + Http::assertSent(function ($request) use ($device) { + return $request->hasHeader('id', $device->mac_address) && + $request->hasHeader('access-token', $device->api_key) && + $request->hasHeader('width', 800) && + $request->hasHeader('height', 480) && + $request->hasHeader('rssi', $device->last_rssi_level) && + $request->hasHeader('battery_voltage', $device->last_battery_voltage) && + $request->hasHeader('refresh-rate', $device->default_refresh_interval) && + $request->hasHeader('fw-version', $device->last_firmware_version); + }); // Assert the device was updated $device->refresh(); @@ -191,7 +201,7 @@ test('it fetches and processes proxy cloud responses for devices with BMP images expect(Storage::disk('public')->exists('images/generated/test-image.png'))->toBeFalse(); }); -test('it fetches and processes proxy cloud responses for devices with PNG images', function (): void { +test('it fetches and processes proxy cloud responses for devices with PNG images', function () { config(['services.trmnl.proxy_base_url' => 'https://example.com']); // Create a test device with proxy cloud enabled @@ -219,14 +229,16 @@ test('it fetches and processes proxy cloud responses for devices with PNG images $job->handle(); // Assert HTTP requests were made with correct headers - Http::assertSent(fn ($request): bool => $request->hasHeader('id', $device->mac_address) && - $request->hasHeader('access-token', $device->api_key) && - $request->hasHeader('width', 800) && - $request->hasHeader('height', 480) && - $request->hasHeader('rssi', $device->last_rssi_level) && - $request->hasHeader('battery_voltage', $device->last_battery_voltage) && - $request->hasHeader('refresh-rate', $device->default_refresh_interval) && - $request->hasHeader('fw-version', $device->last_firmware_version)); + Http::assertSent(function ($request) use ($device) { + return $request->hasHeader('id', $device->mac_address) && + $request->hasHeader('access-token', $device->api_key) && + $request->hasHeader('width', 800) && + $request->hasHeader('height', 480) && + $request->hasHeader('rssi', $device->last_rssi_level) && + $request->hasHeader('battery_voltage', $device->last_battery_voltage) && + $request->hasHeader('refresh-rate', $device->default_refresh_interval) && + $request->hasHeader('fw-version', $device->last_firmware_version); + }); // Assert the device was updated $device->refresh(); @@ -242,7 +254,7 @@ test('it fetches and processes proxy cloud responses for devices with PNG images expect(Storage::disk('public')->exists('images/generated/test-image.bmp'))->toBeFalse(); }); -test('it handles missing content type in image URL gracefully', function (): void { +test('it handles missing content type in image URL gracefully', function () { config(['services.trmnl.proxy_base_url' => 'https://example.com']); // Create a test device with proxy cloud enabled diff --git a/tests/Feature/GenerateDefaultImagesTest.php b/tests/Feature/GenerateDefaultImagesTest.php deleted file mode 100644 index 5a7b69a..0000000 --- a/tests/Feature/GenerateDefaultImagesTest.php +++ /dev/null @@ -1,113 +0,0 @@ -makeDirectory('/images/default-screens'); - Storage::disk('public')->makeDirectory('/images/generated'); - - // Create fallback image files that the service expects - Storage::disk('public')->put('/images/setup-logo.bmp', 'fake-bmp-content'); - Storage::disk('public')->put('/images/sleep.bmp', 'fake-bmp-content'); -}); - -test('command transforms default images for all device models', function (): void { - // Ensure we have device models - $deviceModels = DeviceModel::all(); - expect($deviceModels)->not->toBeEmpty(); - - // Run the command - $this->artisan('images:generate-defaults') - ->assertExitCode(0); - - // Check that the default-screens directory was created - expect(Storage::disk('public')->exists('images/default-screens'))->toBeTrue(); - - // Check that images were generated for each device model - foreach ($deviceModels as $deviceModel) { - $extension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; - $filename = "{$deviceModel->width}_{$deviceModel->height}_{$deviceModel->bit_depth}_{$deviceModel->rotation}.{$extension}"; - - $setupPath = "images/default-screens/setup-logo_{$filename}"; - $sleepPath = "images/default-screens/sleep_{$filename}"; - - expect(Storage::disk('public')->exists($setupPath))->toBeTrue(); - expect(Storage::disk('public')->exists($sleepPath))->toBeTrue(); - } -}); - -test('getDeviceSpecificDefaultImage returns correct path for device with model', function (): void { - $deviceModel = DeviceModel::first(); - expect($deviceModel)->not->toBeNull(); - - $extension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; - $filename = "{$deviceModel->width}_{$deviceModel->height}_{$deviceModel->bit_depth}_{$deviceModel->rotation}.{$extension}"; - - $setupPath = "images/default-screens/setup-logo_{$filename}"; - $sleepPath = "images/default-screens/sleep_{$filename}"; - - Storage::disk('public')->put($setupPath, 'fake-device-specific-setup'); - Storage::disk('public')->put($sleepPath, 'fake-device-specific-sleep'); - - $device = new Device(); - $device->deviceModel = $deviceModel; - - $setupImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo'); - $sleepImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'sleep'); - - expect($setupImage)->toBe($setupPath); - expect($sleepImage)->toBe($sleepPath); -}); - -test('getDeviceSpecificDefaultImage falls back to original images for device without model', function (): void { - $device = new Device(); - $device->deviceModel = null; - - $setupImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo'); - $sleepImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'sleep'); - - expect($setupImage)->toBe('images/setup-logo.bmp'); - expect($sleepImage)->toBe('images/sleep.bmp'); -}); - -test('generateDefaultScreenImage creates images from Blade templates', function (): void { - $device = Device::factory()->create(); - - $setupUuid = ImageGenerationService::generateDefaultScreenImage($device, 'setup-logo'); - $sleepUuid = ImageGenerationService::generateDefaultScreenImage($device, 'sleep'); - - expect($setupUuid)->not->toBeEmpty(); - expect($sleepUuid)->not->toBeEmpty(); - expect($setupUuid)->not->toBe($sleepUuid); - - $setupPath = "images/generated/{$setupUuid}.png"; - $sleepPath = "images/generated/{$sleepUuid}.png"; - - Storage::disk('public')->put($setupPath, 'fake-generated-setup-image'); - Storage::disk('public')->put($sleepPath, 'fake-generated-sleep-image'); - - expect(Storage::disk('public')->exists($setupPath))->toBeTrue(); - expect(Storage::disk('public')->exists($sleepPath))->toBeTrue(); -}); - -test('generateDefaultScreenImage throws exception for invalid image type', function (): void { - $device = Device::factory()->create(); - - expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-type')) - ->toThrow(InvalidArgumentException::class); -}); - -test('getDeviceSpecificDefaultImage returns null for invalid image type', function (): void { - $device = new Device(); - $device->deviceModel = DeviceModel::first(); - - $result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'invalid-type'); - expect($result)->toBeNull(); -}); diff --git a/tests/Feature/GenerateScreenJobTest.php b/tests/Feature/GenerateScreenJobTest.php index 115fb51..35b4377 100644 --- a/tests/Feature/GenerateScreenJobTest.php +++ b/tests/Feature/GenerateScreenJobTest.php @@ -2,18 +2,16 @@ use App\Jobs\GenerateScreenJob; use App\Models\Device; -use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Illuminate\Support\Facades\Storage; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -beforeEach(function (): void { - TrmnlPipeline::fake(); +beforeEach(function () { Storage::fake('public'); Storage::disk('public')->makeDirectory('/images/generated'); }); -test('it generates screen images and updates device', function (): void { +test('it generates screen images and updates device', function () { $device = Device::factory()->create(); $job = new GenerateScreenJob($device->id, null, view('trmnl')->render()); $job->handle(); @@ -25,9 +23,9 @@ test('it generates screen images and updates device', function (): void { // Assert both PNG and BMP files were created $uuid = $device->current_screen_image; Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); -}); +})->skipOnCi(); -test('it cleans up unused images', function (): void { +test('it cleans up unused images', function () { // Create some test devices with images $activeDevice = Device::factory()->create([ 'current_screen_image' => 'uuid-to-be-replaced', @@ -47,9 +45,9 @@ test('it cleans up unused images', function (): void { Storage::disk('public')->assertMissing('/images/generated/uuid-to-be-replaced.bmp'); Storage::disk('public')->assertMissing('/images/generated/inactive-uuid.png'); Storage::disk('public')->assertMissing('/images/generated/inactive-uuid.bmp'); -}); +})->skipOnCi(); -test('it preserves gitignore file during cleanup', function (): void { +test('it preserves gitignore file during cleanup', function () { Storage::disk('public')->put('/images/generated/.gitignore', '*'); $device = Device::factory()->create(); @@ -57,4 +55,4 @@ test('it preserves gitignore file during cleanup', function (): void { $job->handle(); Storage::disk('public')->assertExists('/images/generated/.gitignore'); -}); +})->skipOnCi(); diff --git a/tests/Feature/ImageGenerationServiceTest.php b/tests/Feature/ImageGenerationServiceTest.php index 07bb6a6..f2af102 100644 --- a/tests/Feature/ImageGenerationServiceTest.php +++ b/tests/Feature/ImageGenerationServiceTest.php @@ -6,7 +6,6 @@ use App\Enums\ImageFormat; use App\Models\Device; use App\Models\DeviceModel; use App\Services\ImageGenerationService; -use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; @@ -15,11 +14,6 @@ uses(RefreshDatabase::class); beforeEach(function (): void { Storage::fake('public'); Storage::disk('public')->makeDirectory('/images/generated'); - TrmnlPipeline::fake(); -}); - -afterEach(function (): void { - TrmnlPipeline::restore(); }); it('generates image for device without device model', function (): void { @@ -40,7 +34,7 @@ it('generates image for device without device model', function (): void { // Assert PNG file was created Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); -}); +})->skipOnCi(); it('generates image for device with device model', function (): void { // Create a DeviceModel @@ -70,7 +64,68 @@ it('generates image for device with device model', function (): void { // Assert PNG file was created Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); -}); +})->skipOnCi(); + +it('generates 4-color 2-bit PNG with device model', function (): void { + // Create a DeviceModel for 4-color, 2-bit PNG + $deviceModel = DeviceModel::factory()->create([ + 'width' => 800, + 'height' => 480, + 'colors' => 4, + 'bit_depth' => 2, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + ]); + + // Create a device with the DeviceModel + $device = Device::factory()->create([ + 'device_model_id' => $deviceModel->id, + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert PNG file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); + + // Verify the image file has content and isn't blank + $imagePath = Storage::disk('public')->path("/images/generated/{$uuid}.png"); + $imageSize = filesize($imagePath); + expect($imageSize)->toBeGreaterThan(200); // Should be at least 200 bytes for a 2-bit PNG + + // Verify it's a valid PNG file + $imageInfo = getimagesize($imagePath); + expect($imageInfo[0])->toBe(800); // Width + expect($imageInfo[1])->toBe(480); // Height + expect($imageInfo[2])->toBe(IMAGETYPE_PNG); // PNG type + + // Debug: Check if the image has any non-transparent pixels + $image = imagecreatefrompng($imagePath); + $width = imagesx($image); + $height = imagesy($image); + $hasContent = false; + + // Check a few sample pixels to see if there's content + for ($x = 0; $x < min(10, $width); $x += 2) { + for ($y = 0; $y < min(10, $height); $y += 2) { + $color = imagecolorat($image, $x, $y); + if ($color !== 0) { // Not black/transparent + $hasContent = true; + break 2; + } + } + } + + imagedestroy($image); + expect($hasContent)->toBe(true, 'Image should contain visible content'); +})->skipOnCi(); it('generates BMP with device model', function (): void { // Create a DeviceModel for BMP format @@ -100,7 +155,7 @@ it('generates BMP with device model', function (): void { // Assert BMP file was created Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp"); -}); +})->skipOnCi(); it('applies scale factor from device model', function (): void { // Create a DeviceModel with scale factor @@ -130,7 +185,7 @@ it('applies scale factor from device model', function (): void { // Assert PNG file was created Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); -}); +})->skipOnCi(); it('applies rotation from device model', function (): void { // Create a DeviceModel with rotation @@ -160,7 +215,7 @@ it('applies rotation from device model', function (): void { // Assert PNG file was created Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); -}); +})->skipOnCi(); it('applies offset from device model', function (): void { // Create a DeviceModel with offset @@ -190,7 +245,7 @@ it('applies offset from device model', function (): void { // Assert PNG file was created Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); -}); +})->skipOnCi(); it('falls back to device settings when no device model', function (): void { // Create a device with custom settings but no DeviceModel @@ -210,7 +265,7 @@ it('falls back to device settings when no device model', function (): void { // Assert PNG file was created Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); -}); +})->skipOnCi(); it('handles auto image format for legacy devices', function (): void { // Create a device with AUTO format (legacy behavior) @@ -231,7 +286,7 @@ it('handles auto image format for legacy devices', function (): void { // Assert PNG file was created (modern firmware defaults to PNG) Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); -}); +})->skipOnCi(); it('cleanupFolder removes unused images', function (): void { // Create active devices with images @@ -254,7 +309,7 @@ it('cleanupFolder removes unused images', function (): void { // Assert inactive files are removed Storage::disk('public')->assertMissing('/images/generated/inactive-uuid.png'); Storage::disk('public')->assertMissing('/images/generated/another-inactive.png'); -}); +})->skipOnCi(); it('cleanupFolder preserves .gitignore', function (): void { // Create gitignore file @@ -268,7 +323,7 @@ it('cleanupFolder preserves .gitignore', function (): void { // Assert gitignore is preserved Storage::disk('public')->assertExists('/images/generated/.gitignore'); -}); +})->skipOnCi(); it('resetIfNotCacheable resets when device models exist', function (): void { // Create a plugin @@ -285,7 +340,7 @@ it('resetIfNotCacheable resets when device models exist', function (): void { // Assert plugin image was reset $plugin->refresh(); expect($plugin->current_image)->toBeNull(); -}); +})->skipOnCi(); it('resetIfNotCacheable resets when custom dimensions exist', function (): void { // Create a plugin @@ -303,7 +358,7 @@ it('resetIfNotCacheable resets when custom dimensions exist', function (): void // Assert plugin image was reset $plugin->refresh(); expect($plugin->current_image)->toBeNull(); -}); +})->skipOnCi(); it('resetIfNotCacheable preserves image for standard devices', function (): void { // Create a plugin @@ -322,31 +377,7 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void // Assert plugin image was preserved $plugin->refresh(); expect($plugin->current_image)->toBe('test-uuid'); -}); - -it('cache is reset when plugin markup changes', function (): void { - // Create a plugin with cached image - $plugin = App\Models\Plugin::factory()->create([ - 'current_image' => 'cached-uuid', - 'render_markup' => '
Original markup
', - ]); - - // Create devices with standard dimensions (cacheable) - Device::factory()->count(2)->create([ - 'width' => 800, - 'height' => 480, - 'rotate' => 0, - ]); - - // Update the plugin markup - $plugin->update([ - 'render_markup' => '
Updated markup
', - ]); - - // Assert cache was reset when markup changed - $plugin->refresh(); - expect($plugin->current_image)->toBeNull(); -}); +})->skipOnCi(); it('determines correct image format from device model', function (): void { // Test BMP format detection @@ -391,7 +422,7 @@ it('determines correct image format from device model', function (): void { $device3->refresh(); expect($device3->current_screen_image)->toBe($uuid3); Storage::disk('public')->assertExists("/images/generated/{$uuid3}.png"); -}); +})->skipOnCi(); it('generates BMP for legacy device with bmp3_1bit_srgb format', function (): void { // Create a device with BMP format but no DeviceModel (legacy behavior) @@ -423,4 +454,4 @@ it('generates BMP for legacy device with bmp3_1bit_srgb format', function (): vo expect($imageInfo[0])->toBe(800); // Width expect($imageInfo[1])->toBe(480); // Height expect($imageInfo[2])->toBe(IMAGETYPE_BMP); // BMP type -}); +})->skipOnCi(); diff --git a/tests/Feature/ImageGenerationWithoutFakeTest.php b/tests/Feature/ImageGenerationWithoutFakeTest.php deleted file mode 100644 index ff70174..0000000 --- a/tests/Feature/ImageGenerationWithoutFakeTest.php +++ /dev/null @@ -1,55 +0,0 @@ -makeDirectory('/images/generated'); -}); - -it('generates 4-color 2-bit PNG with device model', function (): void { - // Create a DeviceModel for 4-color, 2-bit PNG - $deviceModel = DeviceModel::factory()->create([ - 'width' => 800, - 'height' => 480, - 'colors' => 4, - 'bit_depth' => 2, - 'scale_factor' => 1.0, - 'rotation' => 0, - 'mime_type' => 'image/png', - 'offset_x' => 0, - 'offset_y' => 0, - ]); - - // Create a device with the DeviceModel - $device = Device::factory()->create([ - 'device_model_id' => $deviceModel->id, - ]); - - $markup = '
Test Content
'; - $uuid = ImageGenerationService::generateImage($markup, $device->id); - - // Assert the device was updated with a new image UUID - $device->refresh(); - expect($device->current_screen_image)->toBe($uuid); - - // Assert PNG file was created - Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); - - // Verify the image file has content and isn't blank - $imagePath = Storage::disk('public')->path("/images/generated/{$uuid}.png"); - $imageSize = filesize($imagePath); - expect($imageSize)->toBeGreaterThan(200); // Should be at least 200 bytes for a 2-bit PNG - - // Verify it's a valid PNG file - $imageInfo = getimagesize($imagePath); - expect($imageInfo[0])->toBe(800); // Width - expect($imageInfo[1])->toBe(480); // Height - expect($imageInfo[2])->toBe(IMAGETYPE_PNG); // PNG type - -})->skipOnCI(); diff --git a/tests/Feature/Jobs/CleanupDeviceLogsJobTest.php b/tests/Feature/Jobs/CleanupDeviceLogsJobTest.php index ae2833b..15888c0 100644 --- a/tests/Feature/Jobs/CleanupDeviceLogsJobTest.php +++ b/tests/Feature/Jobs/CleanupDeviceLogsJobTest.php @@ -7,7 +7,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -test('it keeps only the 50 most recent logs per device', function (): void { +test('it keeps only the 50 most recent logs per device', function () { // Create two devices $device1 = Device::factory()->create(); $device2 = Device::factory()->create(); diff --git a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php deleted file mode 100644 index f0be135..0000000 --- a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php +++ /dev/null @@ -1,409 +0,0 @@ - Http::response([ - 'data' => [], - ], 200), - ]); -}); - -test('fetch device models job can be dispatched', function (): void { - $job = new FetchDeviceModelsJob(); - expect($job)->toBeInstanceOf(FetchDeviceModelsJob::class); -}); - -test('fetch device models job handles successful api response', function (): void { - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => Http::response([ - 'data' => [ - [ - 'name' => 'test-model', - 'label' => 'Test Model', - 'description' => 'A test device model', - 'width' => 800, - 'height' => 480, - 'colors' => 4, - 'bit_depth' => 2, - 'scale_factor' => 1.0, - 'rotation' => 0, - 'mime_type' => 'image/png', - 'offset_x' => 0, - 'offset_y' => 0, - 'kind' => 'trmnl', - 'published_at' => '2023-01-01T00:00:00Z', - ], - ], - ], 200), - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated device models', ['count' => 1]); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - $deviceModel = DeviceModel::where('name', 'test-model')->first(); - expect($deviceModel)->not->toBeNull(); - expect($deviceModel->label)->toBe('Test Model'); - expect($deviceModel->description)->toBe('A test device model'); - expect($deviceModel->width)->toBe(800); - expect($deviceModel->height)->toBe(480); - expect($deviceModel->colors)->toBe(4); - expect($deviceModel->bit_depth)->toBe(2); - expect($deviceModel->scale_factor)->toBe(1.0); - expect($deviceModel->rotation)->toBe(0); - expect($deviceModel->mime_type)->toBe('image/png'); - expect($deviceModel->offset_x)->toBe(0); - expect($deviceModel->offset_y)->toBe(0); - // expect($deviceModel->kind)->toBe('trmnl'); - expect($deviceModel->source)->toBe('api'); -}); - -test('fetch device models job handles multiple device models', function (): void { - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => Http::response([ - 'data' => [ - [ - 'name' => 'model-1', - 'label' => 'Model 1', - 'description' => 'First model', - 'width' => 800, - 'height' => 480, - 'colors' => 4, - 'bit_depth' => 2, - 'scale_factor' => 1.0, - 'rotation' => 0, - 'mime_type' => 'image/png', - 'offset_x' => 0, - 'offset_y' => 0, - 'published_at' => '2023-01-01T00:00:00Z', - ], - [ - 'name' => 'model-2', - 'label' => 'Model 2', - 'description' => 'Second model', - 'width' => 1200, - 'height' => 800, - 'colors' => 16, - 'bit_depth' => 4, - 'scale_factor' => 1.5, - 'rotation' => 90, - 'mime_type' => 'image/bmp', - 'offset_x' => 10, - 'offset_y' => 20, - 'published_at' => '2023-01-02T00:00:00Z', - ], - ], - ], 200), - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated device models', ['count' => 2]); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - expect(DeviceModel::where('name', 'model-1')->exists())->toBeTrue(); - expect(DeviceModel::where('name', 'model-2')->exists())->toBeTrue(); -}); - -test('fetch device models job handles empty data array', function (): void { - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => Http::response([ - 'data' => [], - ], 200), - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated device models', ['count' => 0]); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - expect(DeviceModel::count())->toBe(0); -}); - -test('fetch device models job handles missing data field', function (): void { - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => Http::response([ - 'message' => 'No data available', - ], 200), - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated device models', ['count' => 0]); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - expect(DeviceModel::count())->toBe(0); -}); - -test('fetch device models job handles non-array data', function (): void { - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => Http::response([ - 'data' => 'invalid-data', - ], 200), - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('error') - ->once() - ->with('Invalid response format from device models API', Mockery::type('array')); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - expect(DeviceModel::count())->toBe(0); -}); - -test('fetch device models job handles api failure', function (): void { - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => Http::response([ - 'error' => 'Internal Server Error', - ], 500), - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('error') - ->once() - ->with('Failed to fetch device models from API', [ - 'status' => 500, - 'body' => '{"error":"Internal Server Error"}', - ]); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - expect(DeviceModel::count())->toBe(0); -}); - -test('fetch device models job handles network exception', function (): void { - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => function (): void { - throw new Exception('Network connection failed'); - }, - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('error') - ->once() - ->with('Exception occurred while fetching device models', Mockery::type('array')); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - expect(DeviceModel::count())->toBe(0); -}); - -test('fetch device models job handles device model with missing name', function (): void { - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => Http::response([ - 'data' => [ - [ - 'label' => 'Model without name', - 'description' => 'This model has no name', - ], - ], - ], 200), - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('warning') - ->once() - ->with('Device model data missing name field', Mockery::type('array')); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated device models', ['count' => 1]); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - expect(DeviceModel::count())->toBe(0); -}); - -test('fetch device models job handles device model with partial data', function (): void { - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => Http::response([ - 'data' => [ - [ - 'name' => 'minimal-model', - // Only name provided, other fields should use defaults - ], - ], - ], 200), - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated device models', ['count' => 1]); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - $deviceModel = DeviceModel::where('name', 'minimal-model')->first(); - expect($deviceModel)->not->toBeNull(); - expect($deviceModel->label)->toBe(''); - expect($deviceModel->description)->toBe(''); - expect($deviceModel->width)->toBe(0); - expect($deviceModel->height)->toBe(0); - expect($deviceModel->colors)->toBe(0); - expect($deviceModel->bit_depth)->toBe(0); - expect($deviceModel->scale_factor)->toBe(1.0); - expect($deviceModel->rotation)->toBe(0); - expect($deviceModel->mime_type)->toBe(''); - expect($deviceModel->offset_x)->toBe(0); - expect($deviceModel->offset_y)->toBe(0); - expect($deviceModel->kind)->toBeNull(); - expect($deviceModel->source)->toBe('api'); -}); - -test('fetch device models job updates existing device model', function (): void { - // Create an existing device model - $existingModel = DeviceModel::factory()->create([ - 'name' => 'existing-model', - 'label' => 'Old Label', - 'width' => 400, - 'height' => 300, - ]); - - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => Http::response([ - 'data' => [ - [ - 'name' => 'existing-model', - 'label' => 'Updated Label', - 'description' => 'Updated description', - 'width' => 800, - 'height' => 600, - 'colors' => 4, - 'bit_depth' => 2, - 'scale_factor' => 1.0, - 'rotation' => 0, - 'mime_type' => 'image/png', - 'offset_x' => 0, - 'offset_y' => 0, - 'published_at' => '2023-01-01T00:00:00Z', - ], - ], - ], 200), - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated device models', ['count' => 1]); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - $existingModel->refresh(); - expect($existingModel->label)->toBe('Updated Label'); - expect($existingModel->description)->toBe('Updated description'); - expect($existingModel->width)->toBe(800); - expect($existingModel->height)->toBe(600); - expect($existingModel->source)->toBe('api'); -}); - -test('fetch device models job handles processing exception for individual model', function (): void { - Http::fake([ - 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), - 'usetrmnl.com/api/models' => Http::response([ - 'data' => [ - [ - 'name' => 'valid-model', - 'label' => 'Valid Model', - 'width' => 800, - 'height' => 480, - ], - [ - 'name' => null, // This will cause an exception in processing - 'label' => 'Invalid Model', - ], - ], - ], 200), - ]); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated palettes', ['count' => 0]); - - Log::shouldReceive('warning') - ->once() - ->with('Device model data missing name field', Mockery::type('array')); - - Log::shouldReceive('info') - ->once() - ->with('Successfully fetched and updated device models', ['count' => 2]); - - $job = new FetchDeviceModelsJob(); - $job->handle(); - - // Should still create the valid model - expect(DeviceModel::where('name', 'valid-model')->exists())->toBeTrue(); - expect(DeviceModel::count())->toBe(1); -}); diff --git a/tests/Feature/Jobs/FirmwareDownloadJobTest.php b/tests/Feature/Jobs/FirmwareDownloadJobTest.php index f9109bb..8d09866 100644 --- a/tests/Feature/Jobs/FirmwareDownloadJobTest.php +++ b/tests/Feature/Jobs/FirmwareDownloadJobTest.php @@ -5,16 +5,15 @@ use App\Models\Firmware; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; -beforeEach(function (): void { +beforeEach(function () { Storage::fake('public'); Storage::disk('public')->makeDirectory('/firmwares'); }); -test('it creates firmwares directory if it does not exist', function (): void { +test('it creates firmwares directory if it does not exist', function () { $firmware = Firmware::factory()->create([ 'url' => 'https://example.com/firmware.bin', 'version_tag' => '1.0.0', - 'storage_location' => null, ]); Http::fake([ @@ -26,7 +25,7 @@ test('it creates firmwares directory if it does not exist', function (): void { expect(Storage::disk('public')->exists('firmwares'))->toBeTrue(); }); -test('it downloads firmware and updates storage location', function (): void { +test('it downloads firmware and updates storage location', function () { Http::fake([ 'https://example.com/firmware.bin' => Http::response('fake firmware content', 200), ]); @@ -34,127 +33,9 @@ test('it downloads firmware and updates storage location', function (): void { $firmware = Firmware::factory()->create([ 'url' => 'https://example.com/firmware.bin', 'version_tag' => '1.0.0', - 'storage_location' => null, ]); (new FirmwareDownloadJob($firmware))->handle(); expect($firmware->fresh()->storage_location)->toBe('firmwares/FW1.0.0.bin'); }); - -test('it handles connection exception gracefully', function (): void { - $firmware = Firmware::factory()->create([ - 'url' => 'https://example.com/firmware.bin', - 'version_tag' => '1.0.0', - 'storage_location' => null, - ]); - - Http::fake([ - 'https://example.com/firmware.bin' => function (): void { - throw new Illuminate\Http\Client\ConnectionException('Connection failed'); - }, - ]); - - Illuminate\Support\Facades\Log::shouldReceive('error') - ->once() - ->with('Firmware download failed: Connection failed'); - - (new FirmwareDownloadJob($firmware))->handle(); - - // Storage location should not be updated on failure - expect($firmware->fresh()->storage_location)->toBeNull(); -}); - -test('it handles general exception gracefully', function (): void { - $firmware = Firmware::factory()->create([ - 'url' => 'https://example.com/firmware.bin', - 'version_tag' => '1.0.0', - 'storage_location' => null, - ]); - - Http::fake([ - 'https://example.com/firmware.bin' => function (): void { - throw new Exception('Unexpected error'); - }, - ]); - - Illuminate\Support\Facades\Log::shouldReceive('error') - ->once() - ->with('An unexpected error occurred: Unexpected error'); - - (new FirmwareDownloadJob($firmware))->handle(); - - // Storage location should not be updated on failure - expect($firmware->fresh()->storage_location)->toBeNull(); -}); - -test('it handles firmware with special characters in version tag', function (): void { - Http::fake([ - 'https://example.com/firmware.bin' => Http::response('fake firmware content', 200), - ]); - - $firmware = Firmware::factory()->create([ - 'url' => 'https://example.com/firmware.bin', - 'version_tag' => '1.0.0-beta', - ]); - - (new FirmwareDownloadJob($firmware))->handle(); - - expect($firmware->fresh()->storage_location)->toBe('firmwares/FW1.0.0-beta.bin'); -}); - -test('it handles firmware with long version tag', function (): void { - Http::fake([ - 'https://example.com/firmware.bin' => Http::response('fake firmware content', 200), - ]); - - $firmware = Firmware::factory()->create([ - 'url' => 'https://example.com/firmware.bin', - 'version_tag' => '1.0.0.1234.5678.90', - ]); - - (new FirmwareDownloadJob($firmware))->handle(); - - expect($firmware->fresh()->storage_location)->toBe('firmwares/FW1.0.0.1234.5678.90.bin'); -}); - -test('it creates firmwares directory even when it already exists', function (): void { - $firmware = Firmware::factory()->create([ - 'url' => 'https://example.com/firmware.bin', - 'version_tag' => '1.0.0', - 'storage_location' => null, - ]); - - Http::fake([ - 'https://example.com/firmware.bin' => Http::response('fake firmware content', 200), - ]); - - // Directory already exists from beforeEach - expect(Storage::disk('public')->exists('firmwares'))->toBeTrue(); - - (new FirmwareDownloadJob($firmware))->handle(); - - // Should still work fine - expect($firmware->fresh()->storage_location)->toBe('firmwares/FW1.0.0.bin'); -}); - -test('it handles http error response', function (): void { - $firmware = Firmware::factory()->create([ - 'url' => 'https://example.com/firmware.bin', - 'version_tag' => '1.0.0', - 'storage_location' => null, - ]); - - Http::fake([ - 'https://example.com/firmware.bin' => Http::response('Not Found', 404), - ]); - - Illuminate\Support\Facades\Log::shouldReceive('error') - ->once() - ->with(Mockery::type('string')); - - (new FirmwareDownloadJob($firmware))->handle(); - - // Storage location should not be updated on failure - expect($firmware->fresh()->storage_location)->toBeNull(); -}); diff --git a/tests/Feature/Jobs/FirmwarePollJobTest.php b/tests/Feature/Jobs/FirmwarePollJobTest.php index 74c3cf7..751bc8c 100644 --- a/tests/Feature/Jobs/FirmwarePollJobTest.php +++ b/tests/Feature/Jobs/FirmwarePollJobTest.php @@ -5,11 +5,11 @@ use App\Models\Firmware; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; -beforeEach(function (): void { +beforeEach(function () { Http::preventStrayRequests(); }); -test('it creates new firmware record when polling', function (): void { +test('it creates new firmware record when polling', function () { Http::fake([ 'https://usetrmnl.com/api/firmware/latest' => Http::response([ 'version' => '1.0.0', @@ -25,7 +25,7 @@ test('it creates new firmware record when polling', function (): void { ->latest->toBeTrue(); }); -test('it updates existing firmware record when polling', function (): void { +test('it updates existing firmware record when polling', function () { $existingFirmware = Firmware::factory()->create([ 'version_tag' => '1.0.0', 'url' => 'https://old-url.com/firmware.bin', @@ -46,7 +46,7 @@ test('it updates existing firmware record when polling', function (): void { ->latest->toBeTrue(); }); -test('it marks previous firmware as not latest when new version is found', function (): void { +test('it marks previous firmware as not latest when new version is found', function () { $oldFirmware = Firmware::factory()->create([ 'version_tag' => '1.0.0', 'latest' => true, @@ -65,9 +65,9 @@ test('it marks previous firmware as not latest when new version is found', funct ->and(Firmware::where('version_tag', '1.1.0')->first()->latest)->toBeTrue(); }); -test('it handles connection exception gracefully', function (): void { +test('it handles connection exception gracefully', function () { Http::fake([ - 'https://usetrmnl.com/api/firmware/latest' => function (): void { + 'https://usetrmnl.com/api/firmware/latest' => function () { throw new ConnectionException('Connection failed'); }, ]); @@ -78,7 +78,7 @@ test('it handles connection exception gracefully', function (): void { expect(Firmware::count())->toBe(0); }); -test('it handles invalid response gracefully', function (): void { +test('it handles invalid response gracefully', function () { Http::fake([ 'https://usetrmnl.com/api/firmware/latest' => Http::response(null, 200), ]); @@ -89,7 +89,7 @@ test('it handles invalid response gracefully', function (): void { expect(Firmware::count())->toBe(0); }); -test('it handles missing version in response gracefully', function (): void { +test('it handles missing version in response gracefully', function () { Http::fake([ 'https://usetrmnl.com/api/firmware/latest' => Http::response([ 'url' => 'https://example.com/firmware.bin', @@ -102,7 +102,7 @@ test('it handles missing version in response gracefully', function (): void { expect(Firmware::count())->toBe(0); }); -test('it handles missing url in response gracefully', function (): void { +test('it handles missing url in response gracefully', function () { Http::fake([ 'https://usetrmnl.com/api/firmware/latest' => Http::response([ 'version' => '1.0.0', diff --git a/tests/Feature/Jobs/NotifyDeviceBatteryLowJobTest.php b/tests/Feature/Jobs/NotifyDeviceBatteryLowJobTest.php deleted file mode 100644 index 6d69924..0000000 --- a/tests/Feature/Jobs/NotifyDeviceBatteryLowJobTest.php +++ /dev/null @@ -1,140 +0,0 @@ - 20]); - - $user = User::factory()->create(); - $device = Device::factory()->create([ - 'user_id' => $user->id, - 'last_battery_voltage' => 3.0, // This should result in low battery percentage - 'battery_notification_sent' => false, - ]); - - $job = new NotifyDeviceBatteryLowJob(); - $job->handle(); - - Notification::assertSentTo($user, BatteryLow::class); - - $device->refresh(); - expect($device->battery_notification_sent)->toBeTrue(); -}); - -test('it does not send notification when battery is above threshold', function (): void { - Notification::fake(); - - config(['app.notifications.battery_low.warn_at_percent' => 20]); - - $user = User::factory()->create(); - $device = Device::factory()->create([ - 'user_id' => $user->id, - 'last_battery_voltage' => 4.0, // This should result in high battery percentage - 'battery_notification_sent' => false, - ]); - - $job = new NotifyDeviceBatteryLowJob(); - $job->handle(); - - Notification::assertNotSentTo($user, BatteryLow::class); - - $device->refresh(); - expect($device->battery_notification_sent)->toBeFalse(); -}); - -test('it does not send notification when already sent', function (): void { - Notification::fake(); - - config(['app.notifications.battery_low.warn_at_percent' => 20]); - - $user = User::factory()->create(); - $device = Device::factory()->create([ - 'user_id' => $user->id, - 'last_battery_voltage' => 3.0, // Low battery - 'battery_notification_sent' => true, // Already sent - ]); - - $job = new NotifyDeviceBatteryLowJob(); - $job->handle(); - - Notification::assertNotSentTo($user, BatteryLow::class); -}); - -test('it resets notification flag when battery is above threshold', function (): void { - Notification::fake(); - - config(['app.notifications.battery_low.warn_at_percent' => 20]); - - $user = User::factory()->create(); - $device = Device::factory()->create([ - 'user_id' => $user->id, - 'last_battery_voltage' => 4.0, // High battery - 'battery_notification_sent' => true, // Was previously sent - ]); - - $job = new NotifyDeviceBatteryLowJob(); - $job->handle(); - - Notification::assertNotSentTo($user, BatteryLow::class); - - $device->refresh(); - expect($device->battery_notification_sent)->toBeFalse(); -}); - -test('it skips devices without associated user', function (): void { - Notification::fake(); - - config(['app.notifications.battery_low.warn_at_percent' => 20]); - - $device = Device::factory()->create([ - 'user_id' => null, - 'last_battery_voltage' => 3.0, // Low battery - 'battery_notification_sent' => false, - ]); - - $job = new NotifyDeviceBatteryLowJob(); - $job->handle(); - - Notification::assertNothingSent(); -}); - -test('it processes multiple devices correctly', function (): void { - Notification::fake(); - - config(['app.notifications.battery_low.warn_at_percent' => 20]); - - $user1 = User::factory()->create(); - $user2 = User::factory()->create(); - - $device1 = Device::factory()->create([ - 'user_id' => $user1->id, - 'last_battery_voltage' => 3.0, // Low battery - 'battery_notification_sent' => false, - ]); - - $device2 = Device::factory()->create([ - 'user_id' => $user2->id, - 'last_battery_voltage' => 4.0, // High battery - 'battery_notification_sent' => false, - ]); - - $job = new NotifyDeviceBatteryLowJob(); - $job->handle(); - - Notification::assertSentTo($user1, BatteryLow::class); - Notification::assertNotSentTo($user2, BatteryLow::class); - - $device1->refresh(); - $device2->refresh(); - - expect($device1->battery_notification_sent)->toBeTrue(); - expect($device2->battery_notification_sent)->toBeFalse(); -}); diff --git a/tests/Feature/Livewire/Actions/DeviceAutoJoinTest.php b/tests/Feature/Livewire/Actions/DeviceAutoJoinTest.php deleted file mode 100644 index 5d8b057..0000000 --- a/tests/Feature/Livewire/Actions/DeviceAutoJoinTest.php +++ /dev/null @@ -1,115 +0,0 @@ -create(['assign_new_devices' => false]); - - Livewire::actingAs($user) - ->test(DeviceAutoJoin::class) - ->assertSee('Permit Auto-Join') - ->assertSet('deviceAutojoin', false) - ->assertSet('isFirstUser', true); -}); - -test('device auto join component initializes with user settings', function (): void { - $user = User::factory()->create(['assign_new_devices' => true]); - - Livewire::actingAs($user) - ->test(DeviceAutoJoin::class) - ->assertSet('deviceAutojoin', true) - ->assertSet('isFirstUser', true); -}); - -test('device auto join component identifies first user correctly', function (): void { - $firstUser = User::factory()->create(['id' => 1, 'assign_new_devices' => false]); - $otherUser = User::factory()->create(['id' => 2, 'assign_new_devices' => false]); - - Livewire::actingAs($firstUser) - ->test(DeviceAutoJoin::class) - ->assertSet('isFirstUser', true); - - Livewire::actingAs($otherUser) - ->test(DeviceAutoJoin::class) - ->assertSet('isFirstUser', false); -}); - -test('device auto join component updates user setting when toggled', function (): void { - $user = User::factory()->create(['assign_new_devices' => false]); - - Livewire::actingAs($user) - ->test(DeviceAutoJoin::class) - ->set('deviceAutojoin', true) - ->assertSet('deviceAutojoin', true); - - $user->refresh(); - expect($user->assign_new_devices)->toBeTrue(); -}); - -// Validation test removed - Livewire automatically handles boolean conversion - -test('device auto join component handles false value correctly', function (): void { - $user = User::factory()->create(['assign_new_devices' => true]); - - Livewire::actingAs($user) - ->test(DeviceAutoJoin::class) - ->set('deviceAutojoin', false) - ->assertSet('deviceAutojoin', false); - - $user->refresh(); - expect($user->assign_new_devices)->toBeFalse(); -}); - -test('device auto join component only updates when deviceAutojoin property changes', function (): void { - $user = User::factory()->create(['assign_new_devices' => false]); - - $component = Livewire::actingAs($user) - ->test(DeviceAutoJoin::class); - - // Set a different property to ensure it doesn't trigger the update - $component->set('isFirstUser', true); - - $user->refresh(); - expect($user->assign_new_devices)->toBeFalse(); -}); - -test('device auto join component renders correct view', function (): void { - $user = User::factory()->create(); - - Livewire::actingAs($user) - ->test(DeviceAutoJoin::class) - ->assertViewIs('livewire.actions.device-auto-join'); -}); - -test('device auto join component works with authenticated user', function (): void { - $user = User::factory()->create(['assign_new_devices' => true]); - - $component = Livewire::actingAs($user) - ->test(DeviceAutoJoin::class); - - expect($component->instance()->deviceAutojoin)->toBeTrue(); - expect($component->instance()->isFirstUser)->toBe($user->id === 1); -}); - -test('device auto join component handles multiple updates correctly', function (): void { - $user = User::factory()->create(['assign_new_devices' => false]); - - $component = Livewire::actingAs($user) - ->test(DeviceAutoJoin::class) - ->set('deviceAutojoin', true); - - $user->refresh(); - expect($user->assign_new_devices)->toBeTrue(); - - $component->set('deviceAutojoin', false); - - $user->refresh(); - expect($user->assign_new_devices)->toBeFalse(); -}); diff --git a/tests/Feature/Livewire/Catalog/IndexTest.php b/tests/Feature/Livewire/Catalog/IndexTest.php deleted file mode 100644 index 1b2efba..0000000 --- a/tests/Feature/Livewire/Catalog/IndexTest.php +++ /dev/null @@ -1,199 +0,0 @@ - Http::response('', 200), - ]); - - Livewire::withoutLazyLoading(); - - $component = Volt::test('catalog.index'); - - $component->assertSee('No plugins available'); -}); - -it('loads plugins from catalog URL', function (): void { - // 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', - ], - 'byos' => [ - 'byos_laravel' => [ - 'compatibility' => true, - ], - ], - '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), - ]); - - Livewire::withoutLazyLoading(); - - $component = Volt::test('catalog.index'); - - $component->assertSee('Test Plugin'); - $component->assertSee('testuser'); - $component->assertSee('A test plugin'); - $component->assertSee('MIT'); - $component->assertSee('Preview'); -}); - -it('hides preview button when screenshot_url is missing', function (): void { - // Clear cache first to ensure fresh data - Cache::forget('catalog_plugins'); - - // Mock the HTTP response for the catalog URL without screenshot_url - $catalogData = [ - 'test-plugin' => [ - 'name' => 'Test Plugin Without Screenshot', - 'author' => ['name' => 'Test Author', 'github' => 'testuser'], - 'author_bio' => [ - 'description' => 'A test plugin', - ], - 'license' => 'MIT', - 'trmnlp' => [ - 'zip_url' => 'https://example.com/plugin.zip', - ], - 'byos' => [ - 'byos_laravel' => [ - 'compatibility' => true, - ], - ], - 'logo_url' => 'https://example.com/logo.png', - 'screenshot_url' => null, - ], - ]; - - $yamlContent = Yaml::dump($catalogData); - - Http::fake([ - config('app.catalog_url') => Http::response($yamlContent, 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.index') - ->assertSee('Test Plugin Without Screenshot') - ->assertDontSeeHtml('variant="subtle" icon="eye"'); -}); - -it('shows error when plugin not found', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - Livewire::withoutLazyLoading(); - - $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 (): void { - $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); - - Livewire::withoutLazyLoading(); - - $component = Volt::test('catalog.index'); - - $component->call('installPlugin', 'test-plugin'); - - // The component should dispatch an error notification - $component->assertHasErrors(); - -}); - -it('can preview a plugin', function (): void { - // 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 description', - ], - 'license' => 'MIT', - 'trmnlp' => [ - 'zip_url' => 'https://example.com/plugin.zip', - ], - 'byos' => [ - 'byos_laravel' => [ - 'compatibility' => true, - ], - ], - 'logo_url' => 'https://example.com/logo.png', - 'screenshot_url' => 'https://example.com/screenshot.png', - ], - ]; - - $yamlContent = Yaml::dump($catalogData); - - Http::fake([ - config('app.catalog_url') => Http::response($yamlContent, 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.index') - ->assertSee('Test Plugin') - ->call('previewPlugin', 'test-plugin') - ->assertSet('previewingPlugin', 'test-plugin') - ->assertSet('previewData.name', 'Test Plugin') - ->assertSee('Preview Test Plugin') - ->assertSee('A test plugin description'); -}); diff --git a/tests/Feature/Livewire/Plugins/ConfigModalTest.php b/tests/Feature/Livewire/Plugins/ConfigModalTest.php deleted file mode 100644 index 4372991..0000000 --- a/tests/Feature/Livewire/Plugins/ConfigModalTest.php +++ /dev/null @@ -1,124 +0,0 @@ -create(); - $this->actingAs($user); - - $plugin = Plugin::create([ - 'uuid' => Str::uuid(), - 'user_id' => $user->id, - 'name' => 'Test Plugin', - 'data_strategy' => 'static', - 'configuration_template' => [ - 'custom_fields' => [[ - 'keyname' => 'tags', - 'field_type' => 'multi_string', - 'name' => 'Reading Days', - 'default' => 'alpha,beta', - ]] - ], - 'configuration' => ['tags' => 'alpha,beta'] - ]); - - Volt::test('plugins.config-modal', ['plugin' => $plugin]) - ->assertSet('multiValues.tags', ['alpha', 'beta']); -}); - -test('config modal validates against commas in multi_string boxes', function (): void { - $user = User::factory()->create(); - $this->actingAs($user); - - $plugin = Plugin::create([ - 'uuid' => Str::uuid(), - 'user_id' => $user->id, - 'name' => 'Test Plugin', - 'data_strategy' => 'static', - 'configuration_template' => [ - 'custom_fields' => [[ - 'keyname' => 'tags', - 'field_type' => 'multi_string', - 'name' => 'Reading Days', - ]] - ] - ]); - - Volt::test('plugins.config-modal', ['plugin' => $plugin]) - ->set('multiValues.tags.0', 'no,commas,allowed') - ->call('saveConfiguration') - ->assertHasErrors(['multiValues.tags.0' => 'regex']); - - // Assert DB remains unchanged - expect($plugin->fresh()->configuration['tags'] ?? '')->not->toBe('no,commas,allowed'); -}); - -test('config modal merges multi_string boxes into a single CSV string on save', function (): void { - $user = User::factory()->create(); - $this->actingAs($user); - - $plugin = Plugin::create([ - 'uuid' => Str::uuid(), - 'user_id' => $user->id, - 'name' => 'Test Plugin', - 'data_strategy' => 'static', - 'configuration_template' => [ - 'custom_fields' => [[ - 'keyname' => 'items', - 'field_type' => 'multi_string', - 'name' => 'Reading Days', - ]] - ], - 'configuration' => [] - ]); - - Volt::test('plugins.config-modal', ['plugin' => $plugin]) - ->set('multiValues.items.0', 'First') - ->call('addMultiItem', 'items') - ->set('multiValues.items.1', 'Second') - ->call('saveConfiguration') - ->assertHasNoErrors(); - - expect($plugin->fresh()->configuration['items'])->toBe('First,Second'); -}); - -test('config modal resetForm clears dirty state and increments resetIndex', function (): void { - $user = User::factory()->create(); - $this->actingAs($user); - - $plugin = Plugin::create([ - 'uuid' => Str::uuid(), - 'user_id' => $user->id, - 'name' => 'Test Plugin', - 'data_strategy' => 'static', - 'configuration' => ['simple_key' => 'original_value'] - ]); - - Volt::test('plugins.config-modal', ['plugin' => $plugin]) - ->set('configuration.simple_key', 'dirty_value') - ->call('resetForm') - ->assertSet('configuration.simple_key', 'original_value') - ->assertSet('resetIndex', 1); -}); - -test('config modal dispatches update event for parent warning refresh', function (): void { - $user = User::factory()->create(); - $this->actingAs($user); - - $plugin = Plugin::create([ - 'uuid' => Str::uuid(), - 'user_id' => $user->id, - 'name' => 'Test Plugin', - 'data_strategy' => 'static' - ]); - - Volt::test('plugins.config-modal', ['plugin' => $plugin]) - ->call('saveConfiguration') - ->assertDispatched('config-updated'); -}); diff --git a/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php b/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php deleted file mode 100644 index a04815f..0000000 --- a/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php +++ /dev/null @@ -1,112 +0,0 @@ -create(); - $this->actingAs($user); - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'trmnlp_id' => null, - ]); - - $trmnlpId = (string) Str::uuid(); - - Volt::test('plugins.recipes.settings', ['plugin' => $plugin]) - ->set('trmnlp_id', $trmnlpId) - ->call('saveTrmnlpId') - ->assertHasNoErrors(); - - expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId); -}); - -test('recipe settings validates trmnlp_id is unique per user', function (): void { - $user = User::factory()->create(); - $this->actingAs($user); - - $existingPlugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'trmnlp_id' => 'existing-id-123', - ]); - - $newPlugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'trmnlp_id' => null, - ]); - - Volt::test('plugins.recipes.settings', ['plugin' => $newPlugin]) - ->set('trmnlp_id', 'existing-id-123') - ->call('saveTrmnlpId') - ->assertHasErrors(['trmnlp_id' => 'unique']); - - expect($newPlugin->fresh()->trmnlp_id)->toBeNull(); -}); - -test('recipe settings allows same trmnlp_id for different users', function (): void { - $user1 = User::factory()->create(); - $user2 = User::factory()->create(); - - $plugin1 = Plugin::factory()->create([ - 'user_id' => $user1->id, - 'trmnlp_id' => 'shared-id-123', - ]); - - $plugin2 = Plugin::factory()->create([ - 'user_id' => $user2->id, - 'trmnlp_id' => null, - ]); - - $this->actingAs($user2); - - Volt::test('plugins.recipes.settings', ['plugin' => $plugin2]) - ->set('trmnlp_id', 'shared-id-123') - ->call('saveTrmnlpId') - ->assertHasNoErrors(); - - expect($plugin2->fresh()->trmnlp_id)->toBe('shared-id-123'); -}); - -test('recipe settings allows same trmnlp_id for the same plugin', function (): void { - $user = User::factory()->create(); - $this->actingAs($user); - - $trmnlpId = (string) Str::uuid(); - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'trmnlp_id' => $trmnlpId, - ]); - - Volt::test('plugins.recipes.settings', ['plugin' => $plugin]) - ->set('trmnlp_id', $trmnlpId) - ->call('saveTrmnlpId') - ->assertHasNoErrors(); - - expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId); -}); - -test('recipe settings can clear trmnlp_id', function (): void { - $user = User::factory()->create(); - $this->actingAs($user); - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'trmnlp_id' => 'some-id', - ]); - - Volt::test('plugins.recipes.settings', ['plugin' => $plugin]) - ->set('trmnlp_id', '') - ->call('saveTrmnlpId') - ->assertHasNoErrors(); - - expect($plugin->fresh()->trmnlp_id)->toBeNull(); -}); diff --git a/tests/Feature/PlaylistSchedulingTest.php b/tests/Feature/PlaylistSchedulingTest.php index 18d0032..43ad663 100644 --- a/tests/Feature/PlaylistSchedulingTest.php +++ b/tests/Feature/PlaylistSchedulingTest.php @@ -10,7 +10,7 @@ use Illuminate\Support\Carbon; uses(RefreshDatabase::class); -test('playlist scheduling works correctly for time ranges spanning midnight', function (): void { +test('playlist scheduling works correctly for time ranges spanning midnight', function () { // Create a user and device $user = User::factory()->create(); $device = Device::factory()->create(['user_id' => $user->id]); @@ -85,7 +85,7 @@ test('playlist scheduling works correctly for time ranges spanning midnight', fu expect($nextItem->playlist->name)->toBe('Day until Deep Night Playlist'); }); -test('playlist isActiveNow handles midnight spanning correctly', function (): void { +test('playlist isActiveNow handles midnight spanning correctly', function () { $playlist = Playlist::factory()->create([ 'is_active' => true, 'active_from' => '09:01', @@ -110,7 +110,7 @@ test('playlist isActiveNow handles midnight spanning correctly', function (): vo expect($playlist->isActiveNow())->toBeFalse(); }); -test('playlist isActiveNow handles normal time ranges correctly', function (): void { +test('playlist isActiveNow handles normal time ranges correctly', function () { $playlist = Playlist::factory()->create([ 'is_active' => true, 'active_from' => '09:00', @@ -130,48 +130,3 @@ test('playlist isActiveNow handles normal time ranges correctly', function (): v Carbon::setTestNow(Carbon::create(2024, 1, 1, 20, 0, 0)); expect($playlist->isActiveNow())->toBeFalse(); }); - -test('playlist scheduling respects user timezone preference', function (): void { - // Create a user with a timezone that's UTC+1 (e.g., Europe/Berlin) - // This simulates the bug where setting 00:15 doesn't work until one hour later - $user = User::factory()->create([ - 'timezone' => 'Europe/Berlin', // UTC+1 in winter, UTC+2 in summer - ]); - - $device = Device::factory()->create(['user_id' => $user->id]); - - // Create a playlist that should be active from 00:15 to 01:00 in the user's timezone - $playlist = Playlist::factory()->create([ - 'device_id' => $device->id, - 'is_active' => true, - 'active_from' => '00:15', - 'active_until' => '01:00', - 'weekdays' => null, - ]); - - // Set test time to 00:15 in the user's timezone (Europe/Berlin) - // In January, Europe/Berlin is UTC+1, so 00:15 Berlin time = 23:15 UTC the previous day - // But Carbon::setTestNow uses UTC by default, so we need to set it to the UTC equivalent - // For January 1, 2024 at 00:15 Berlin time (UTC+1), that's December 31, 2023 at 23:15 UTC - $berlinTime = Carbon::create(2024, 1, 1, 0, 15, 0, 'Europe/Berlin'); - Carbon::setTestNow($berlinTime->utc()); - - // The playlist should be active at 00:15 in the user's timezone - // This test should pass after the fix, but will fail with the current bug - expect($playlist->isActiveNow())->toBeTrue(); - - // Test at 00:30 in user's timezone - should still be active - $berlinTime = Carbon::create(2024, 1, 1, 0, 30, 0, 'Europe/Berlin'); - Carbon::setTestNow($berlinTime->utc()); - expect($playlist->isActiveNow())->toBeTrue(); - - // Test at 01:15 in user's timezone - should NOT be active (past the end time) - $berlinTime = Carbon::create(2024, 1, 1, 1, 15, 0, 'Europe/Berlin'); - Carbon::setTestNow($berlinTime->utc()); - expect($playlist->isActiveNow())->toBeFalse(); - - // Test at 00:10 in user's timezone - should NOT be active (before start time) - $berlinTime = Carbon::create(2024, 1, 1, 0, 10, 0, 'Europe/Berlin'); - Carbon::setTestNow($berlinTime->utc()); - expect($playlist->isActiveNow())->toBeFalse(); -}); diff --git a/tests/Feature/PluginArchiveTest.php b/tests/Feature/PluginArchiveTest.php index 9a95379..9a7f66c 100644 --- a/tests/Feature/PluginArchiveTest.php +++ b/tests/Feature/PluginArchiveTest.php @@ -9,11 +9,11 @@ use App\Services\PluginImportService; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; -beforeEach(function (): void { +beforeEach(function () { Storage::fake('local'); }); -it('exports plugin to zip file in correct format', function (): void { +it('exports plugin to zip file in correct format', function () { $user = User::factory()->create(); $plugin = Plugin::factory()->create([ 'user_id' => $user->id, @@ -42,7 +42,7 @@ it('exports plugin to zip file in correct format', function (): void { expect($response->getFile()->getFilename())->toContain('test-plugin-123.zip'); }); -it('exports plugin with polling configuration', function (): void { +it('exports plugin with polling configuration', function () { $user = User::factory()->create(); $plugin = Plugin::factory()->create([ 'user_id' => $user->id, @@ -63,7 +63,7 @@ it('exports plugin with polling configuration', function (): void { expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); }); -it('exports and imports plugin maintaining all data', function (): void { +it('exports and imports plugin maintaining all data', function () { $user = User::factory()->create(); $originalPlugin = Plugin::factory()->create([ 'user_id' => $user->id, @@ -122,7 +122,7 @@ it('exports and imports plugin maintaining all data', function (): void { expect($importedPlugin->data_payload)->toBe(['items' => [1, 2, 3]]); }); -it('handles blade templates correctly', function (): void { +it('handles blade templates correctly', function () { $user = User::factory()->create(); $plugin = Plugin::factory()->create([ 'user_id' => $user->id, @@ -138,7 +138,7 @@ it('handles blade templates correctly', function (): void { expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); }); -it('removes wrapper div from exported markup', function (): void { +it('removes wrapper div from exported markup', function () { $user = User::factory()->create(); $plugin = Plugin::factory()->create([ 'user_id' => $user->id, @@ -154,7 +154,7 @@ it('removes wrapper div from exported markup', function (): void { expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); }); -it('converts polling headers correctly', function (): void { +it('converts polling headers correctly', function () { $user = User::factory()->create(); $plugin = Plugin::factory()->create([ 'user_id' => $user->id, @@ -170,7 +170,7 @@ it('converts polling headers correctly', function (): void { expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); }); -it('api route returns zip file for authenticated user', function (): void { +it('api route returns zip file for authenticated user', function () { $user = User::factory()->create(); $plugin = Plugin::factory()->create([ 'user_id' => $user->id, @@ -188,7 +188,7 @@ it('api route returns zip file for authenticated user', function (): void { $response->assertHeader('Content-Disposition', 'attachment; filename=plugin_api-test-404.zip'); }); -it('api route returns 404 for non-existent plugin', function (): void { +it('api route returns 404 for non-existent plugin', function () { $user = User::factory()->create(); $response = $this->actingAs($user) @@ -197,13 +197,13 @@ it('api route returns 404 for non-existent plugin', function (): void { $response->assertStatus(404); }); -it('api route returns 401 for unauthenticated user', function (): void { +it('api route returns 401 for unauthenticated user', function () { $response = $this->getJson('/api/plugin_settings/test-id/archive'); $response->assertStatus(401); }); -it('api route returns 404 for plugin belonging to different user', function (): void { +it('api route returns 404 for plugin belonging to different user', function () { $user1 = User::factory()->create(); $user2 = User::factory()->create(); $plugin = Plugin::factory()->create([ @@ -217,7 +217,7 @@ it('api route returns 404 for plugin belonging to different user', function (): $response->assertStatus(404); }); -it('exports zip with files in root directory', function (): void { +it('exports zip with files in root directory', function () { $user = User::factory()->create(); $plugin = Plugin::factory()->create([ 'user_id' => $user->id, @@ -243,7 +243,7 @@ it('exports zip with files in root directory', function (): void { $zip->close(); }); -it('maintains correct yaml field order', function (): void { +it('maintains correct yaml field order', function () { $user = User::factory()->create(); $plugin = Plugin::factory()->create([ 'user_id' => $user->id, diff --git a/tests/Feature/PluginDefaultValuesTest.php b/tests/Feature/PluginDefaultValuesTest.php index 353ad0c..30f62c0 100644 --- a/tests/Feature/PluginDefaultValuesTest.php +++ b/tests/Feature/PluginDefaultValuesTest.php @@ -6,7 +6,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -test('plugin import extracts default values from custom_fields and stores in configuration', function (): void { +test('plugin import extracts default values from custom_fields and stores in configuration', function () { // Create a user $user = User::factory()->create(); @@ -74,7 +74,7 @@ test('plugin import extracts default values from custom_fields and stores in con expect($plugin->configuration_template['custom_fields'])->toHaveCount(3); }); -test('plugin import handles custom_fields without default values', function (): void { +test('plugin import handles custom_fields without default values', function () { // Create a user $user = User::factory()->create(); diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php index f3ef1fa..9aeda6e 100644 --- a/tests/Feature/PluginImportTest.php +++ b/tests/Feature/PluginImportTest.php @@ -6,14 +6,13 @@ 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 (): void { +beforeEach(function () { Storage::fake('local'); }); -it('imports plugin from valid zip file', function (): void { +it('imports plugin from valid zip file', function () { $user = User::factory()->create(); // Create a mock ZIP file with the required structure @@ -38,7 +37,7 @@ it('imports plugin from valid zip file', function (): void { ->and($plugin->configuration['api_key'])->toBe('default-api-key'); }); -it('imports plugin with shared.liquid file', function (): void { +it('imports plugin with shared.liquid file', function () { $user = User::factory()->create(); $zipContent = createMockZipFile([ @@ -56,7 +55,7 @@ it('imports plugin with shared.liquid file', function (): void { ->and($plugin->render_markup)->toContain('
'); }); -it('imports plugin with files in root directory', function (): void { +it('imports plugin with files in root directory', function () { $user = User::factory()->create(); $zipContent = createMockZipFile([ @@ -73,47 +72,32 @@ it('imports plugin with files in root directory', function (): void { ->and($plugin->name)->toBe('Test Plugin'); }); -it('throws exception for invalid zip file', function (): void { +it('throws exception for invalid zip file', function () { $user = User::factory()->create(); $zipFile = UploadedFile::fake()->createWithContent('invalid.zip', 'not a zip file'); $pluginImportService = new PluginImportService(); - expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user)) + expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) ->toThrow(Exception::class, 'Could not open the ZIP file.'); }); -it('throws exception for missing settings.yml', function (): void { - $user = User::factory()->create(); - - $zipContent = createMockZipFile([ - 'src/full.liquid' => getValidFullLiquid(), - // Missing settings.yml - ]); - - $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); - - $pluginImportService = new PluginImportService(); - expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user)) - ->toThrow(Exception::class, 'Invalid ZIP structure. Required file settings.yml is missing.'); -}); - -it('throws exception for missing template files', function (): void { +it('throws exception for missing required files', function () { $user = User::factory()->create(); $zipContent = createMockZipFile([ 'src/settings.yml' => getValidSettingsYaml(), - // Missing all template files + // Missing full.liquid ]); $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); $pluginImportService = new PluginImportService(); - expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user)) - ->toThrow(Exception::class, 'Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.'); + expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) + ->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.'); }); -it('sets default values when settings are missing', function (): void { +it('sets default values when settings are missing', function () { $user = User::factory()->create(); $zipContent = createMockZipFile([ @@ -132,7 +116,7 @@ it('sets default values when settings are missing', function (): void { ->and($plugin->polling_verb)->toBe('get'); // default value }); -it('handles blade markup language correctly', function (): void { +it('handles blade markup language correctly', function () { $user = User::factory()->create(); $zipContent = createMockZipFile([ @@ -145,407 +129,14 @@ it('handles blade markup language correctly', function (): void { $pluginImportService = new PluginImportService(); $plugin = $pluginImportService->importFromZip($zipFile, $user); - expect($plugin->markup_language)->toBe('blade') - ->and($plugin->render_markup)->not->toContain('
') - ->and($plugin->render_markup)->toBe('
Blade template
'); -}); - -it('imports plugin from monorepo with zip_entry_path parameter', function (): void { - $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 (): void { - $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 (): void { - $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 (): void { - $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(fn ($request): bool => $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 (): void { - $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 (): void { - $user = User::factory()->create(); - - // Create a mock ZIP file that simulates GitHub's ZIP structure with repository-named directory - $zipContent = createMockZipFile([ - 'example-repo-main/another-plugin/src/settings.yml' => "name: Other Plugin\nrefresh_interval: 60\nstrategy: static\npolling_verb: get\nstatic_data: '{}'\ncustom_fields: []", - 'example-repo-main/another-plugin/src/full.liquid' => '
Other content
', - 'example-repo-main/example-plugin/src/settings.yml' => getValidSettingsYaml(), - 'example-repo-main/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-repo-main/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 (): void { - $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 (): void { - $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-repo-main/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 (): void { - $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
'); -}); - -it('sets icon_url when importing from URL with iconUrl parameter', function (): void { - $user = User::factory()->create(); - - $zipContent = createMockZipFile([ - 'src/settings.yml' => getValidSettingsYaml(), - 'src/full.liquid' => getValidFullLiquid(), - ]); - - Http::fake([ - 'https://example.com/plugin.zip' => Http::response($zipContent, 200), - ]); - - $pluginImportService = new PluginImportService(); - $plugin = $pluginImportService->importFromUrl( - 'https://example.com/plugin.zip', - $user, - null, - null, - 'https://example.com/icon.png' - ); - - expect($plugin)->toBeInstanceOf(Plugin::class) - ->and($plugin->icon_url)->toBe('https://example.com/icon.png'); -}); - -it('does not set icon_url when importing from URL without iconUrl parameter', function (): void { - $user = User::factory()->create(); - - $zipContent = createMockZipFile([ - 'src/settings.yml' => getValidSettingsYaml(), - 'src/full.liquid' => getValidFullLiquid(), - ]); - - Http::fake([ - 'https://example.com/plugin.zip' => Http::response($zipContent, 200), - ]); - - $pluginImportService = new PluginImportService(); - $plugin = $pluginImportService->importFromUrl( - 'https://example.com/plugin.zip', - $user - ); - - expect($plugin)->toBeInstanceOf(Plugin::class) - ->and($plugin->icon_url)->toBeNull(); -}); - -it('normalizes non-named select options to named values', function (): void { - $user = User::factory()->create(); - - $settingsYaml = <<<'YAML' -name: Test Plugin -refresh_interval: 30 -strategy: static -polling_verb: get -static_data: '{}' -custom_fields: - - keyname: display_incident - field_type: select - options: - - true - - false - default: true -YAML; - - $zipContent = createMockZipFile([ - 'src/settings.yml' => $settingsYaml, - 'src/full.liquid' => getValidFullLiquid(), - ]); - - $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); - - $pluginImportService = new PluginImportService(); - $plugin = $pluginImportService->importFromZip($zipFile, $user); - - $customFields = $plugin->configuration_template['custom_fields']; - $displayIncidentField = collect($customFields)->firstWhere('keyname', 'display_incident'); - - expect($displayIncidentField)->not->toBeNull() - ->and($displayIncidentField['options'])->toBe([ - ['true' => 'true'], - ['false' => 'false'], - ]) - ->and($displayIncidentField['default'])->toBe('true'); -}); - -it('throws exception when multi_string default value contains a comma', function (): void { - $user = User::factory()->create(); - - // YAML with a comma in the 'default' field of a multi_string - $invalidYaml = <<<'YAML' -name: Test Plugin -refresh_interval: 30 -strategy: static -polling_verb: get -static_data: '{"test": "data"}' -custom_fields: - - keyname: api_key - field_type: multi_string - default: default-api-key1,default-api-key2 - label: API Key -YAML; - - $zipContent = createMockZipFile([ - 'src/settings.yml' => $invalidYaml, - 'src/full.liquid' => getValidFullLiquid(), - ]); - - $zipFile = UploadedFile::fake()->createWithContent('invalid-default.zip', $zipContent); - $pluginImportService = new PluginImportService(); - - expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) - ->toThrow(Exception::class, 'Validation Error: The default value for multistring fields like `api_key` cannot contain commas.'); -}); - -it('throws exception when multi_string placeholder contains a comma', function (): void { - $user = User::factory()->create(); - - // YAML with a comma in the 'placeholder' field - $invalidYaml = <<<'YAML' -name: Test Plugin -refresh_interval: 30 -strategy: static -polling_verb: get -static_data: '{"test": "data"}' -custom_fields: - - keyname: api_key - field_type: multi_string - default: default-api-key - label: API Key - placeholder: "value1, value2" -YAML; - - $zipContent = createMockZipFile([ - 'src/settings.yml' => $invalidYaml, - 'src/full.liquid' => getValidFullLiquid(), - ]); - - $zipFile = UploadedFile::fake()->createWithContent('invalid-placeholder.zip', $zipContent); - $pluginImportService = new PluginImportService(); - - expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) - ->toThrow(Exception::class, 'Validation Error: The placeholder value for multistring fields like `api_key` cannot contain commas.'); -}); - -it('imports plugin with only shared.liquid file', function (): void { - $user = User::factory()->create(); - - $zipContent = createMockZipFile([ - 'src/settings.yml' => getValidSettingsYaml(), - 'src/shared.liquid' => '
{{ data.title }}
', - ]); - - $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); - - $pluginImportService = new PluginImportService(); - $plugin = $pluginImportService->importFromZip($zipFile, $user); - - expect($plugin)->toBeInstanceOf(Plugin::class) - ->and($plugin->markup_language)->toBe('liquid') - ->and($plugin->render_markup)->toContain('
') - ->and($plugin->render_markup)->toContain('
{{ data.title }}
'); -}); - -it('imports plugin with only shared.blade.php file', function (): void { - $user = User::factory()->create(); - - $zipContent = createMockZipFile([ - 'src/settings.yml' => getValidSettingsYaml(), - 'src/shared.blade.php' => '
{{ $data["title"] }}
', - ]); - - $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); - - $pluginImportService = new PluginImportService(); - $plugin = $pluginImportService->importFromZip($zipFile, $user); - - expect($plugin)->toBeInstanceOf(Plugin::class) - ->and($plugin->markup_language)->toBe('blade') - ->and($plugin->render_markup)->toBe('
{{ $data["title"] }}
') - ->and($plugin->render_markup)->not->toContain('
'); + expect($plugin->markup_language)->toBe('blade'); }); // Helper methods function createMockZipFile(array $files): string { $zip = new ZipArchive(); - - $tempFileName = 'test_zip_'.uniqid().'.zip'; - $tempFile = Storage::path($tempFileName); + $tempFile = tempnam(sys_get_temp_dir(), 'test_zip_'); $zip->open($tempFile, ZipArchive::CREATE); @@ -556,8 +147,7 @@ function createMockZipFile(array $files): string $zip->close(); $content = file_get_contents($tempFile); - - Storage::delete($tempFileName); + unlink($tempFile); return $content; } diff --git a/tests/Feature/PluginInlineTemplatesTest.php b/tests/Feature/PluginInlineTemplatesTest.php index 76b29d7..fb35344 100644 --- a/tests/Feature/PluginInlineTemplatesTest.php +++ b/tests/Feature/PluginInlineTemplatesTest.php @@ -5,7 +5,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -test('renders plugin with inline templates', function (): void { +test('renders plugin with inline templates', function () { $plugin = Plugin::factory()->create([ 'name' => 'Test Plugin', 'markup_language' => 'liquid', @@ -61,16 +61,16 @@ LIQUID // Should render both templates // Check for any of the facts (since random number generation is non-deterministic) $this->assertTrue( - str_contains((string) $result, 'Fact 1') || - str_contains((string) $result, 'Fact 2') || - str_contains((string) $result, 'Fact 3') + str_contains($result, 'Fact 1') || + str_contains($result, 'Fact 2') || + str_contains($result, 'Fact 3') ); $this->assertStringContainsString('Test Plugin', $result); $this->assertStringContainsString('Please try to enjoy each fact equally', $result); $this->assertStringContainsString('class="view view--full"', $result); }); -test('renders plugin with inline templates using with syntax', function (): void { +test('renders plugin with inline templates using with syntax', function () { $plugin = Plugin::factory()->create([ 'name' => 'Test Plugin', 'markup_language' => 'liquid', @@ -127,16 +127,16 @@ LIQUID // Should render both templates // Check for any of the facts (since random number generation is non-deterministic) $this->assertTrue( - str_contains((string) $result, 'Fact 1') || - str_contains((string) $result, 'Fact 2') || - str_contains((string) $result, 'Fact 3') + str_contains($result, 'Fact 1') || + str_contains($result, 'Fact 2') || + str_contains($result, 'Fact 3') ); $this->assertStringContainsString('Test Plugin', $result); $this->assertStringContainsString('Please try to enjoy each fact equally', $result); $this->assertStringContainsString('class="view view--full"', $result); }); -test('renders plugin with simple inline template', function (): void { +test('renders plugin with simple inline template', function () { $plugin = Plugin::factory()->create([ 'markup_language' => 'liquid', 'render_markup' => <<<'LIQUID' @@ -162,7 +162,7 @@ LIQUID $this->assertStringContainsString('class="simple"', $result); }); -test('renders plugin with liquid filter find_by', function (): void { +test('renders plugin with liquid filter find_by', function () { $plugin = Plugin::factory()->create([ 'markup_language' => 'liquid', 'render_markup' => <<<'LIQUID' @@ -194,7 +194,7 @@ LIQUID $this->assertStringContainsString('class="user"', $result); }); -test('renders plugin with liquid filter find_by and fallback', function (): void { +test('renders plugin with liquid filter find_by and fallback', function () { $plugin = Plugin::factory()->create([ 'markup_language' => 'liquid', 'render_markup' => <<<'LIQUID' @@ -216,7 +216,7 @@ LIQUID $this->assertStringContainsString('Not Found', $result); }); -test('renders plugin with liquid filter group_by', function (): void { +test('renders plugin with liquid filter group_by', function () { $plugin = Plugin::factory()->create([ 'markup_language' => 'liquid', 'render_markup' => <<<'LIQUID' diff --git a/tests/Feature/PluginLiquidFilterTest.php b/tests/Feature/PluginLiquidWhereFilterTest.php similarity index 51% rename from tests/Feature/PluginLiquidFilterTest.php rename to tests/Feature/PluginLiquidWhereFilterTest.php index e6272c7..22a2fa5 100644 --- a/tests/Feature/PluginLiquidFilterTest.php +++ b/tests/Feature/PluginLiquidWhereFilterTest.php @@ -2,9 +2,7 @@ declare(strict_types=1); -use App\Liquid\Filters\StandardFilters; use App\Models\Plugin; -use Keepsuit\Liquid\Environment; /** * Tests for the Liquid where filter functionality @@ -14,7 +12,8 @@ use Keepsuit\Liquid\Environment; * to: * {% assign _temp_xxx = collection | filter: "key", "value" %}{% for item in _temp_xxx %} */ -test('where filter works when assigned to variable first', function (): void { + +test('where filter works when assigned to variable first', function () { $plugin = Plugin::factory()->create([ 'markup_language' => 'liquid', 'render_markup' => <<<'LIQUID' @@ -29,6 +28,7 @@ assign collection = json_string | parse_json {{ tide | json }} {%- endfor %} LIQUID + , ]); $result = $plugin->render('full'); @@ -41,7 +41,7 @@ LIQUID $this->assertStringNotContainsString('"type":"L"', $result); }); -test('where filter works directly in for loop with preprocessing', function (): void { +test('where filter works directly in for loop with preprocessing', function () { $plugin = Plugin::factory()->create([ 'markup_language' => 'liquid', 'render_markup' => <<<'LIQUID' @@ -54,6 +54,7 @@ assign collection = json_string | parse_json {{ tide | json }} {%- endfor %} LIQUID + , ]); $result = $plugin->render('full'); @@ -66,7 +67,7 @@ LIQUID $this->assertStringNotContainsString('"type":"L"', $result); }); -test('where filter works directly in for loop with multiple matches', function (): void { +test('where filter works directly in for loop with multiple matches', function () { $plugin = Plugin::factory()->create([ 'markup_language' => 'liquid', 'render_markup' => <<<'LIQUID' @@ -79,6 +80,7 @@ assign collection = json_string | parse_json {{ tide | json }} {%- endfor %} LIQUID + , ]); $result = $plugin->render('full'); @@ -91,86 +93,3 @@ LIQUID // Should not contain the low tide data $this->assertStringNotContainsString('"type":"L"', $result); }); - -it('encodes arrays for url_encode as JSON with spaces after commas and then percent-encodes', function (): void { - /** @var Environment $env */ - $env = app('liquid.environment'); - $env->filterRegistry->register(StandardFilters::class); - - $template = $env->parseString('{{ categories | url_encode }}'); - - $output = $template->render($env->newRenderContext([ - 'categories' => ['common', 'obscure'], - ])); - - expect($output)->toBe('%5B%22common%22%2C%22obscure%22%5D'); -}); - -it('keeps scalar url_encode behavior intact', function (): void { - /** @var Environment $env */ - $env = app('liquid.environment'); - $env->filterRegistry->register(StandardFilters::class); - - $template = $env->parseString('{{ text | url_encode }}'); - - $output = $template->render($env->newRenderContext([ - 'text' => 'hello world', - ])); - - expect($output)->toBe('hello+world'); -}); - -test('where_exp filter works in liquid template', function (): void { - $plugin = Plugin::factory()->create([ - 'markup_language' => 'liquid', - 'render_markup' => <<<'LIQUID' -{% liquid -assign nums = "1, 2, 3, 4, 5" | split: ", " | map_to_i -assign filtered = nums | where_exp: "n", "n >= 3" -%} - -{% for num in filtered %} - {{ num }} -{%- endfor %} -LIQUID - ]); - - $result = $plugin->render('full'); - - // Debug: Let's see what the actual output is - // The issue might be that the HTML contains "1" in other places - // Let's check if the filtered numbers are actually in the content - $this->assertStringContainsString('3', $result); - $this->assertStringContainsString('4', $result); - $this->assertStringContainsString('5', $result); - - // Instead of checking for absence of 1 and 2, let's verify the count - // The filtered result should only contain 3, 4, 5 - $filteredContent = strip_tags((string) $result); - $this->assertStringNotContainsString('1', $filteredContent); - $this->assertStringNotContainsString('2', $filteredContent); -}); - -test('where_exp filter works with object properties', function (): void { - $plugin = Plugin::factory()->create([ - 'markup_language' => 'liquid', - 'render_markup' => <<<'LIQUID' -{% liquid -assign users = '[{"name":"Alice","age":25},{"name":"Bob","age":30},{"name":"Charlie","age":35}]' | parse_json -assign adults = users | where_exp: "user", "user.age >= 30" -%} - -{% for user in adults %} - {{ user.name }} ({{ user.age }}) -{%- endfor %} -LIQUID - ]); - - $result = $plugin->render('full'); - - // Should output users >= 30 - $this->assertStringContainsString('Bob (30)', $result); - $this->assertStringContainsString('Charlie (35)', $result); - // Should not contain users < 30 - $this->assertStringNotContainsString('Alice (25)', $result); -}); diff --git a/tests/Feature/PluginRequiredConfigurationTest.php b/tests/Feature/PluginRequiredConfigurationTest.php index 51e1b76..552b996 100644 --- a/tests/Feature/PluginRequiredConfigurationTest.php +++ b/tests/Feature/PluginRequiredConfigurationTest.php @@ -6,7 +6,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -test('hasMissingRequiredConfigurationFields returns true when required field is null', function (): void { +test('hasMissingRequiredConfigurationFields returns true when required field is null', function () { $user = User::factory()->create(); $configurationTemplate = [ @@ -39,7 +39,7 @@ test('hasMissingRequiredConfigurationFields returns true when required field is expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); }); -test('hasMissingRequiredConfigurationFields returns false when all required fields are set', function (): void { +test('hasMissingRequiredConfigurationFields returns false when all required fields are set', function () { $user = User::factory()->create(); $configurationTemplate = [ @@ -73,7 +73,7 @@ test('hasMissingRequiredConfigurationFields returns false when all required fiel expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); }); -test('hasMissingRequiredConfigurationFields returns false when no custom fields exist', function (): void { +test('hasMissingRequiredConfigurationFields returns false when no custom fields exist', function () { $user = User::factory()->create(); $plugin = Plugin::factory()->create([ @@ -85,7 +85,7 @@ test('hasMissingRequiredConfigurationFields returns false when no custom fields expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); }); -test('hasMissingRequiredConfigurationFields returns true when explicitly required field is null', function (): void { +test('hasMissingRequiredConfigurationFields returns true when explicitly required field is null', function () { $user = User::factory()->create(); $configurationTemplate = [ @@ -111,7 +111,7 @@ test('hasMissingRequiredConfigurationFields returns true when explicitly require expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); }); -test('hasMissingRequiredConfigurationFields returns true when required field is empty string', function (): void { +test('hasMissingRequiredConfigurationFields returns true when required field is empty string', function () { $user = User::factory()->create(); $configurationTemplate = [ @@ -137,7 +137,7 @@ test('hasMissingRequiredConfigurationFields returns true when required field is expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); }); -test('hasMissingRequiredConfigurationFields returns true when required array field is empty', function (): void { +test('hasMissingRequiredConfigurationFields returns true when required array field is empty', function () { $user = User::factory()->create(); $configurationTemplate = [ @@ -164,7 +164,7 @@ test('hasMissingRequiredConfigurationFields returns true when required array fie expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); }); -test('hasMissingRequiredConfigurationFields returns false when author_bio field is present but other required field is set', function (): void { +test('hasMissingRequiredConfigurationFields returns false when author_bio field is present but other required field is set', function () { $user = User::factory()->create(); $configurationTemplate = [ @@ -193,7 +193,7 @@ test('hasMissingRequiredConfigurationFields returns false when author_bio field expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); }); -test('hasMissingRequiredConfigurationFields returns false when field has default value', function (): void { +test('hasMissingRequiredConfigurationFields returns false when field has default value', function () { $user = User::factory()->create(); $configurationTemplate = [ @@ -217,7 +217,7 @@ test('hasMissingRequiredConfigurationFields returns false when field has default expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); }); -test('hasMissingRequiredConfigurationFields returns true when required xhrSelect field is missing', function (): void { +test('hasMissingRequiredConfigurationFields returns true when required xhrSelect field is missing', function () { $user = User::factory()->create(); $configurationTemplate = [ @@ -242,7 +242,7 @@ test('hasMissingRequiredConfigurationFields returns true when required xhrSelect expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); }); -test('hasMissingRequiredConfigurationFields returns false when required xhrSelect field is set', function (): void { +test('hasMissingRequiredConfigurationFields returns false when required xhrSelect field is set', function () { $user = User::factory()->create(); $configurationTemplate = [ @@ -268,79 +268,3 @@ test('hasMissingRequiredConfigurationFields returns false when required xhrSelec expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); }); - -test('hasMissingRequiredConfigurationFields returns true when required multi_string field is missing', function (): void { - $user = User::factory()->create(); - - $configurationTemplate = [ - 'custom_fields' => [ - [ - 'keyname' => 'tags', - 'field_type' => 'multi_string', - 'name' => 'Tags', - 'description' => 'Enter tags separated by commas', - // Not marked as optional, so it's required - ], - ], - ]; - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'configuration_template' => $configurationTemplate, - 'configuration' => [], // Empty configuration - ]); - - expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); -}); - -test('hasMissingRequiredConfigurationFields returns false when required multi_string field is set', function (): void { - $user = User::factory()->create(); - - $configurationTemplate = [ - 'custom_fields' => [ - [ - 'keyname' => 'tags', - 'field_type' => 'multi_string', - 'name' => 'Tags', - 'description' => 'Enter tags separated by commas', - // Not marked as optional, so it's required - ], - ], - ]; - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'configuration_template' => $configurationTemplate, - 'configuration' => [ - 'tags' => 'tag1, tag2, tag3', // Required field is set with comma-separated values - ], - ]); - - expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); -}); - -test('hasMissingRequiredConfigurationFields returns true when required multi_string field is empty string', function (): void { - $user = User::factory()->create(); - - $configurationTemplate = [ - 'custom_fields' => [ - [ - 'keyname' => 'tags', - 'field_type' => 'multi_string', - 'name' => 'Tags', - 'description' => 'Enter tags separated by commas', - // Not marked as optional, so it's required - ], - ], - ]; - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'configuration_template' => $configurationTemplate, - 'configuration' => [ - 'tags' => '', // Empty string - ], - ]); - - expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); -}); diff --git a/tests/Feature/PluginResponseTest.php b/tests/Feature/PluginResponseTest.php deleted file mode 100644 index 2a75c9e..0000000 --- a/tests/Feature/PluginResponseTest.php +++ /dev/null @@ -1,287 +0,0 @@ - Http::response([ - 'title' => 'Test Data', - 'items' => [ - ['id' => 1, 'name' => 'Item 1'], - ['id' => 2, 'name' => 'Item 2'], - ], - ], 200, ['Content-Type' => 'application/json']), - ]); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'polling_url' => 'https://example.com/api/data', - 'polling_verb' => 'get', - ]); - - $plugin->updateDataPayload(); - - $plugin->refresh(); - - expect($plugin->data_payload)->toBe([ - 'title' => 'Test Data', - 'items' => [ - ['id' => 1, 'name' => 'Item 1'], - ['id' => 2, 'name' => 'Item 2'], - ], - ]); -}); - -test('plugin parses XML responses and wraps under rss key', function (): void { - $xmlContent = ' - - - Test RSS Feed - - Test Item 1 - Description 1 - - - Test Item 2 - Description 2 - - - '; - - Http::fake([ - 'example.com/feed.xml' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']), - ]); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'polling_url' => 'https://example.com/feed.xml', - 'polling_verb' => 'get', - ]); - - $plugin->updateDataPayload(); - - $plugin->refresh(); - - expect($plugin->data_payload)->toHaveKey('rss'); - expect($plugin->data_payload['rss'])->toHaveKey('@attributes'); - expect($plugin->data_payload['rss'])->toHaveKey('channel'); - expect($plugin->data_payload['rss']['channel']['title'])->toBe('Test RSS Feed'); - expect($plugin->data_payload['rss']['channel']['item'])->toHaveCount(2); -}); - -test('plugin parses JSON-parsable response body as JSON', function (): void { - $jsonContent = '{"title": "Test Data", "items": [1, 2, 3]}'; - - Http::fake([ - 'example.com/data' => Http::response($jsonContent, 200, ['Content-Type' => 'text/plain']), - ]); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'polling_url' => 'https://example.com/data', - 'polling_verb' => 'get', - ]); - - $plugin->updateDataPayload(); - - $plugin->refresh(); - - expect($plugin->data_payload)->toBe([ - 'title' => 'Test Data', - 'items' => [1, 2, 3], - ]); -}); - -test('plugin wraps plain text response body as JSON', function (): void { - $jsonContent = 'Lorem ipsum dolor sit amet'; - - Http::fake([ - 'example.com/data' => Http::response($jsonContent, 200, ['Content-Type' => 'text/plain']), - ]); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'polling_url' => 'https://example.com/data', - 'polling_verb' => 'get', - ]); - - $plugin->updateDataPayload(); - - $plugin->refresh(); - - expect($plugin->data_payload)->toBe([ - 'data' => 'Lorem ipsum dolor sit amet', - ]); -}); - -test('plugin handles invalid XML gracefully', function (): void { - $invalidXml = 'unclosed tag'; - - Http::fake([ - 'example.com/invalid.xml' => Http::response($invalidXml, 200, ['Content-Type' => 'application/xml']), - ]); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'polling_url' => 'https://example.com/invalid.xml', - 'polling_verb' => 'get', - ]); - - $plugin->updateDataPayload(); - - $plugin->refresh(); - - expect($plugin->data_payload)->toBe(['error' => 'Failed to parse XML response']); -}); - -test('plugin handles multiple URLs with mixed content types', function (): void { - $jsonResponse = ['title' => 'JSON Data', 'items' => [1, 2, 3]]; - $xmlContent = 'XML Data'; - - Http::fake([ - 'example.com/json' => Http::response($jsonResponse, 200, ['Content-Type' => 'application/json']), - 'example.com/xml' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']), - ]); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'polling_url' => "https://example.com/json\nhttps://example.com/xml", - 'polling_verb' => 'get', - ]); - - $plugin->updateDataPayload(); - - $plugin->refresh(); - - expect($plugin->data_payload)->toHaveKey('IDX_0'); - expect($plugin->data_payload)->toHaveKey('IDX_1'); - - // First URL should be JSON - expect($plugin->data_payload['IDX_0'])->toBe($jsonResponse); - - // Second URL should be XML wrapped under rss - expect($plugin->data_payload['IDX_1'])->toHaveKey('rss'); - expect($plugin->data_payload['IDX_1']['rss']['item'])->toBe('XML Data'); -}); - -test('plugin handles POST requests with XML responses', function (): void { - $xmlContent = 'successtest'; - - Http::fake([ - 'example.com/api' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']), - ]); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'polling_url' => 'https://example.com/api', - 'polling_verb' => 'post', - 'polling_body' => '{"query": "test"}', - ]); - - $plugin->updateDataPayload(); - - $plugin->refresh(); - - expect($plugin->data_payload)->toHaveKey('rss'); - expect($plugin->data_payload['rss'])->toHaveKey('status'); - expect($plugin->data_payload['rss'])->toHaveKey('data'); - expect($plugin->data_payload['rss']['status'])->toBe('success'); - expect($plugin->data_payload['rss']['data'])->toBe('test'); -}); - -test('plugin parses iCal responses and filters to recent window', function (): void { - Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC')); - - $icalContent = <<<'ICS' -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Example Corp.//CalDAV Client//EN -BEGIN:VEVENT -UID:event-1@example.com -DTSTAMP:20250101T120000Z -DTSTART:20250110T090000Z -DTEND:20250110T100000Z -SUMMARY:Past within window -END:VEVENT -BEGIN:VEVENT -UID:event-2@example.com -DTSTAMP:20250101T120000Z -DTSTART:20250301T090000Z -DTEND:20250301T100000Z -SUMMARY:Far future -END:VEVENT -BEGIN:VEVENT -UID:event-3@example.com -DTSTAMP:20250101T120000Z -DTSTART:20250120T090000Z -DTEND:20250120T100000Z -SUMMARY:Upcoming within window -END:VEVENT -END:VCALENDAR -ICS; - - Http::fake([ - 'example.com/calendar.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']), - ]); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'polling_url' => 'https://example.com/calendar.ics', - 'polling_verb' => 'get', - ]); - - $plugin->updateDataPayload(); - $plugin->refresh(); - - $ical = $plugin->data_payload['ical']; - - expect($ical)->toHaveCount(2); - expect($ical[0]['SUMMARY'])->toBe('Past within window'); - expect($ical[1]['SUMMARY'])->toBe('Upcoming within window'); - expect(collect($ical)->pluck('SUMMARY'))->not->toContain('Far future'); - expect($ical[0]['DTSTART'])->toBe('2025-01-10T09:00:00+00:00'); - expect($ical[1]['DTSTART'])->toBe('2025-01-20T09:00:00+00:00'); - - Carbon::setTestNow(); -}); - -test('plugin detects iCal content without calendar content type', function (): void { - Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC')); - - $icalContent = <<<'ICS' -BEGIN:VCALENDAR -VERSION:2.0 -BEGIN:VEVENT -UID:event-body-detected@example.com -DTSTAMP:20250101T120000Z -DTSTART:20250116T090000Z -DTEND:20250116T100000Z -SUMMARY:Detected by body -END:VEVENT -END:VCALENDAR -ICS; - - Http::fake([ - 'example.com/calendar-body.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/plain']), - ]); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'polling_url' => 'https://example.com/calendar-body.ics', - 'polling_verb' => 'get', - ]); - - $plugin->updateDataPayload(); - $plugin->refresh(); - - expect($plugin->data_payload)->toHaveKey('ical'); - expect($plugin->data_payload['ical'])->toHaveCount(1); - expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Detected by body'); - expect($plugin->data_payload['ical'][0]['DTSTART'])->toBe('2025-01-16T09:00:00+00:00'); - - Carbon::setTestNow(); -}); diff --git a/tests/Feature/PluginWebhookTest.php b/tests/Feature/PluginWebhookTest.php index 22d1d54..70fa53a 100644 --- a/tests/Feature/PluginWebhookTest.php +++ b/tests/Feature/PluginWebhookTest.php @@ -3,7 +3,7 @@ use App\Models\Plugin; use Illuminate\Support\Str; -test('webhook updates plugin data for webhook strategy', function (): void { +test('webhook updates plugin data for webhook strategy', function () { // Create a plugin with webhook strategy $plugin = Plugin::factory()->create([ 'data_strategy' => 'webhook', @@ -26,7 +26,7 @@ test('webhook updates plugin data for webhook strategy', function (): void { ]); }); -test('webhook returns 400 for non-webhook strategy plugins', function (): void { +test('webhook returns 400 for non-webhook strategy plugins', function () { // Create a plugin with non-webhook strategy $plugin = Plugin::factory()->create([ 'data_strategy' => 'polling', @@ -43,7 +43,7 @@ test('webhook returns 400 for non-webhook strategy plugins', function (): void { ->assertJson(['error' => 'Plugin does not use webhook strategy']); }); -test('webhook returns 400 when merge_variables is missing', function (): void { +test('webhook returns 400 when merge_variables is missing', function () { // Create a plugin with webhook strategy $plugin = Plugin::factory()->create([ 'data_strategy' => 'webhook', @@ -58,7 +58,7 @@ test('webhook returns 400 when merge_variables is missing', function (): void { ->assertJson(['error' => 'Request must contain merge_variables key']); }); -test('webhook returns 404 for non-existent plugin', function (): void { +test('webhook returns 404 for non-existent plugin', function () { // Make request with non-existent plugin UUID $response = $this->postJson('/api/custom_plugins/'.Str::uuid(), [ 'merge_variables' => ['new' => 'data'], diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php index 0e33955..3252860 100644 --- a/tests/Feature/Settings/PasswordUpdateTest.php +++ b/tests/Feature/Settings/PasswordUpdateTest.php @@ -6,7 +6,7 @@ use Livewire\Volt\Volt; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('password can be updated', function (): void { +test('password can be updated', function () { $user = User::factory()->create([ 'password' => Hash::make('password'), ]); @@ -24,7 +24,7 @@ test('password can be updated', function (): void { expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue(); }); -test('correct password must be provided to update password', function (): void { +test('correct password must be provided to update password', function () { $user = User::factory()->create([ 'password' => Hash::make('password'), ]); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index cbf424c..48ea114 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -5,13 +5,13 @@ use Livewire\Volt\Volt; uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('profile page is displayed', function (): void { +test('profile page is displayed', function () { $this->actingAs($user = User::factory()->create()); $this->get('/settings/profile')->assertOk(); }); -test('profile information can be updated', function (): void { +test('profile information can be updated', function () { $user = User::factory()->create(); $this->actingAs($user); @@ -30,7 +30,7 @@ test('profile information can be updated', function (): void { expect($user->email_verified_at)->toBeNull(); }); -test('email verification status is unchanged when email address is unchanged', function (): void { +test('email verification status is unchanged when email address is unchanged', function () { $user = User::factory()->create(); $this->actingAs($user); @@ -45,7 +45,7 @@ test('email verification status is unchanged when email address is unchanged', f expect($user->refresh()->email_verified_at)->not->toBeNull(); }); -test('user can delete their account', function (): void { +test('user can delete their account', function () { $user = User::factory()->create(); $this->actingAs($user); @@ -62,7 +62,7 @@ test('user can delete their account', function (): void { expect(auth()->check())->toBeFalse(); }); -test('correct password must be provided to delete account', function (): void { +test('correct password must be provided to delete account', function () { $user = User::factory()->create(); $this->actingAs($user); diff --git a/tests/Feature/TransformDefaultImagesTest.php b/tests/Feature/TransformDefaultImagesTest.php deleted file mode 100644 index 2ea995f..0000000 --- a/tests/Feature/TransformDefaultImagesTest.php +++ /dev/null @@ -1,87 +0,0 @@ -makeDirectory('/images/default-screens'); - Storage::disk('public')->makeDirectory('/images/generated'); - - // Create fallback image files that the service expects - Storage::disk('public')->put('/images/setup-logo.bmp', 'fake-bmp-content'); - Storage::disk('public')->put('/images/sleep.bmp', 'fake-bmp-content'); -}); - -test('command transforms default images for all device models', function (): void { - // Ensure we have device models - $deviceModels = DeviceModel::all(); - expect($deviceModels)->not->toBeEmpty(); - - // Run the command - $this->artisan('images:generate-defaults') - ->assertExitCode(0); - - // Check that the default-screens directory was created - expect(Storage::disk('public')->exists('images/default-screens'))->toBeTrue(); - - // Check that images were generated for each device model - foreach ($deviceModels as $deviceModel) { - $extension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; - $filename = "{$deviceModel->width}_{$deviceModel->height}_{$deviceModel->bit_depth}_{$deviceModel->rotation}.{$extension}"; - - $setupPath = "images/default-screens/setup-logo_{$filename}"; - $sleepPath = "images/default-screens/sleep_{$filename}"; - - expect(Storage::disk('public')->exists($setupPath))->toBeTrue(); - expect(Storage::disk('public')->exists($sleepPath))->toBeTrue(); - } -}); - -test('getDeviceSpecificDefaultImage falls back to original images for device without model', function (): void { - $device = new Device(); - $device->deviceModel = null; - - $setupImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo'); - $sleepImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'sleep'); - - expect($setupImage)->toBe('images/setup-logo.bmp'); - expect($sleepImage)->toBe('images/sleep.bmp'); -}); - -test('generateDefaultScreenImage creates images from Blade templates', function (): void { - $device = Device::factory()->create(); - - $setupUuid = ImageGenerationService::generateDefaultScreenImage($device, 'setup-logo'); - $sleepUuid = ImageGenerationService::generateDefaultScreenImage($device, 'sleep'); - - expect($setupUuid)->not->toBeEmpty(); - expect($sleepUuid)->not->toBeEmpty(); - expect($setupUuid)->not->toBe($sleepUuid); - - // Check that the generated images exist - $setupPath = "images/generated/{$setupUuid}.png"; - $sleepPath = "images/generated/{$sleepUuid}.png"; - - expect(Storage::disk('public')->exists($setupPath))->toBeTrue(); - expect(Storage::disk('public')->exists($sleepPath))->toBeTrue(); -})->skipOnCI(); - -test('generateDefaultScreenImage throws exception for invalid image type', function (): void { - $device = Device::factory()->create(); - - expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-type')) - ->toThrow(InvalidArgumentException::class); -}); - -test('getDeviceSpecificDefaultImage returns null for invalid image type', function (): void { - $device = new Device(); - $device->deviceModel = DeviceModel::first(); - - $result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'invalid-type'); - expect($result)->toBeNull(); -}); diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php deleted file mode 100644 index a80c63a..0000000 --- a/tests/Feature/Volt/CatalogTrmnlTest.php +++ /dev/null @@ -1,286 +0,0 @@ - Http::response([ - 'data' => [ - [ - 'id' => 123, - 'name' => 'Weather Chum', - 'icon_url' => 'https://example.com/icon.png', - 'screenshot_url' => null, - 'author_bio' => null, - 'stats' => ['installs' => 10, 'forks' => 2], - ], - ], - ], 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Weather Chum') - ->assertSee('Install') - ->assertDontSeeHtml('variant="subtle" icon="eye"') - ->assertSee('Installs: 10'); -}); - -it('shows preview button when screenshot_url is provided', function (): void { - Http::fake([ - 'usetrmnl.com/recipes.json*' => Http::response([ - 'data' => [ - [ - 'id' => 123, - 'name' => 'Weather Chum', - 'icon_url' => 'https://example.com/icon.png', - 'screenshot_url' => 'https://example.com/screenshot.png', - 'author_bio' => null, - 'stats' => ['installs' => 10, 'forks' => 2], - ], - ], - ], 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Weather Chum') - ->assertSee('Preview'); -}); - -it('searches TRMNL recipes when search term is provided', function (): void { - Http::fake([ - // First call (mount -> newest) - 'usetrmnl.com/recipes.json?*' => Http::sequence() - ->push([ - 'data' => [ - [ - 'id' => 1, - 'name' => 'Initial Recipe', - 'icon_url' => null, - 'screenshot_url' => null, - 'author_bio' => null, - 'stats' => ['installs' => 1, 'forks' => 0], - ], - ], - ], 200) - // Second call (search) - ->push([ - 'data' => [ - [ - 'id' => 2, - 'name' => 'Weather Search Result', - 'icon_url' => null, - 'screenshot_url' => null, - 'author_bio' => null, - 'stats' => ['installs' => 3, 'forks' => 1], - ], - ], - ], 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Initial Recipe') - ->set('search', 'weather') - ->assertSee('Weather Search Result') - ->assertSee('Install'); -}); - -it('installs plugin successfully when user is authenticated', function (): void { - $user = User::factory()->create(); - - Http::fake([ - 'usetrmnl.com/recipes.json*' => Http::response([ - 'data' => [ - [ - 'id' => 123, - 'name' => 'Weather Chum', - 'icon_url' => 'https://example.com/icon.png', - 'screenshot_url' => null, - 'author_bio' => null, - 'stats' => ['installs' => 10, 'forks' => 2], - ], - ], - ], 200), - 'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200), - ]); - - $this->actingAs($user); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Weather Chum') - ->call('installPlugin', '123') - ->assertSee('Error installing plugin'); // This will fail because we don't have a real zip file -}); - -it('shows error when user is not authenticated', function (): void { - Http::fake([ - 'usetrmnl.com/recipes.json*' => Http::response([ - 'data' => [ - [ - 'id' => 123, - 'name' => 'Weather Chum', - 'icon_url' => 'https://example.com/icon.png', - 'screenshot_url' => null, - 'author_bio' => null, - 'stats' => ['installs' => 10, 'forks' => 2], - ], - ], - ], 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Weather Chum') - ->call('installPlugin', '123') - ->assertStatus(403); // This will return 403 because user is not authenticated -}); - -it('shows error when plugin installation fails', function (): void { - $user = User::factory()->create(); - - Http::fake([ - 'usetrmnl.com/recipes.json*' => Http::response([ - 'data' => [ - [ - 'id' => 123, - 'name' => 'Weather Chum', - 'icon_url' => 'https://example.com/icon.png', - 'screenshot_url' => null, - 'author_bio' => null, - 'stats' => ['installs' => 10, 'forks' => 2], - ], - ], - ], 200), - 'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200), - ]); - - $this->actingAs($user); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Weather Chum') - ->call('installPlugin', '123') - ->assertSee('Error installing plugin'); // This will fail because the zip content is invalid -}); - -it('previews a recipe with async fetch', function (): void { - Http::fake([ - 'usetrmnl.com/recipes.json*' => Http::response([ - 'data' => [ - [ - 'id' => 123, - 'name' => 'Weather Chum', - 'icon_url' => 'https://example.com/icon.png', - 'screenshot_url' => 'https://example.com/old.png', - 'author_bio' => null, - 'stats' => ['installs' => 10, 'forks' => 2], - ], - ], - ], 200), - 'usetrmnl.com/recipes/123.json' => Http::response([ - 'data' => [ - 'id' => 123, - 'name' => 'Weather Chum Updated', - 'icon_url' => 'https://example.com/icon.png', - 'screenshot_url' => 'https://example.com/new.png', - 'author_bio' => ['description' => 'New bio'], - 'stats' => ['installs' => 11, 'forks' => 3], - ], - ], 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Weather Chum') - ->call('previewRecipe', '123') - ->assertSet('previewingRecipe', '123') - ->assertSet('previewData.name', 'Weather Chum Updated') - ->assertSet('previewData.screenshot_url', 'https://example.com/new.png') - ->assertSee('Preview Weather Chum Updated') - ->assertSee('New bio'); -}); - -it('supports pagination and loading more recipes', function (): void { - Http::fake([ - 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([ - 'data' => [ - [ - 'id' => 1, - 'name' => 'Recipe Page 1', - 'icon_url' => null, - 'screenshot_url' => null, - 'author_bio' => null, - 'stats' => ['installs' => 1, 'forks' => 0], - ], - ], - 'next_page_url' => '/recipes.json?page=2', - ], 200), - 'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([ - 'data' => [ - [ - 'id' => 2, - 'name' => 'Recipe Page 2', - 'icon_url' => null, - 'screenshot_url' => null, - 'author_bio' => null, - 'stats' => ['installs' => 2, 'forks' => 0], - ], - ], - 'next_page_url' => null, - ], 200), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Recipe Page 1') - ->assertDontSee('Recipe Page 2') - ->assertSee('Load next page') - ->call('loadMore') - ->assertSee('Recipe Page 1') - ->assertSee('Recipe Page 2') - ->assertDontSee('Load next page'); -}); - -it('resets pagination when search term changes', function (): void { - Http::fake([ - 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence() - ->push([ - 'data' => [['id' => 1, 'name' => 'Initial 1']], - 'next_page_url' => '/recipes.json?page=2', - ]) - ->push([ - 'data' => [['id' => 3, 'name' => 'Initial 1 Again']], - 'next_page_url' => null, - ]), - 'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([ - 'data' => [['id' => 2, 'name' => 'Weather Result']], - 'next_page_url' => null, - ]), - ]); - - Livewire::withoutLazyLoading(); - - Volt::test('catalog.trmnl') - ->assertSee('Initial 1') - ->call('loadMore') - ->set('search', 'weather') - ->assertSee('Weather Result') - ->assertDontSee('Initial 1') - ->assertSet('page', 1); -}); diff --git a/tests/Feature/Volt/DevicePalettesTest.php b/tests/Feature/Volt/DevicePalettesTest.php deleted file mode 100644 index 376a4a6..0000000 --- a/tests/Feature/Volt/DevicePalettesTest.php +++ /dev/null @@ -1,575 +0,0 @@ -create(); - - $this->actingAs($user); - - $this->get(route('device-palettes.index'))->assertOk(); -}); - -test('component loads all device palettes on mount', function (): void { - $user = User::factory()->create(); - $initialCount = DevicePalette::count(); - DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']); - DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']); - DevicePalette::create(['name' => 'palette-3', 'grays' => 16, 'framework_class' => '']); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index'); - - $palettes = $component->get('devicePalettes'); - expect($palettes)->toHaveCount($initialCount + 3); -}); - -test('can open modal to create new device palette', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->call('openDevicePaletteModal'); - - $component - ->assertSet('editingDevicePaletteId', null) - ->assertSet('viewingDevicePaletteId', null) - ->assertSet('name', null) - ->assertSet('grays', 2); -}); - -test('can create a new device palette', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'test-palette') - ->set('description', 'Test Palette Description') - ->set('grays', 16) - ->set('colors', ['#FF0000', '#00FF00']) - ->set('framework_class', 'TestFramework') - ->call('saveDevicePalette'); - - $component->assertHasNoErrors(); - - expect(DevicePalette::where('name', 'test-palette')->exists())->toBeTrue(); - - $palette = DevicePalette::where('name', 'test-palette')->first(); - expect($palette->description)->toBe('Test Palette Description'); - expect($palette->grays)->toBe(16); - expect($palette->colors)->toBe(['#FF0000', '#00FF00']); - expect($palette->framework_class)->toBe('TestFramework'); - expect($palette->source)->toBe('manual'); -}); - -test('can create a grayscale-only palette without colors', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'grayscale-palette') - ->set('grays', 256) - ->set('colors', []) - ->call('saveDevicePalette'); - - $component->assertHasNoErrors(); - - $palette = DevicePalette::where('name', 'grayscale-palette')->first(); - expect($palette->colors)->toBeNull(); - expect($palette->grays)->toBe(256); -}); - -test('can open modal to edit existing device palette', function (): void { - $user = User::factory()->create(); - $palette = DevicePalette::create([ - 'name' => 'existing-palette', - 'description' => 'Existing Description', - 'grays' => 4, - 'colors' => ['#FF0000', '#00FF00'], - 'framework_class' => 'Framework', - 'source' => 'manual', - ]); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->call('openDevicePaletteModal', $palette->id); - - $component - ->assertSet('editingDevicePaletteId', $palette->id) - ->assertSet('name', 'existing-palette') - ->assertSet('description', 'Existing Description') - ->assertSet('grays', 4) - ->assertSet('colors', ['#FF0000', '#00FF00']) - ->assertSet('framework_class', 'Framework'); -}); - -test('can update an existing device palette', function (): void { - $user = User::factory()->create(); - $palette = DevicePalette::create([ - 'name' => 'original-palette', - 'grays' => 2, - 'framework_class' => '', - 'source' => 'manual', - ]); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->call('openDevicePaletteModal', $palette->id) - ->set('name', 'updated-palette') - ->set('description', 'Updated Description') - ->set('grays', 16) - ->set('colors', ['#0000FF']) - ->call('saveDevicePalette'); - - $component->assertHasNoErrors(); - - $palette->refresh(); - expect($palette->name)->toBe('updated-palette'); - expect($palette->description)->toBe('Updated Description'); - expect($palette->grays)->toBe(16); - expect($palette->colors)->toBe(['#0000FF']); -}); - -test('can delete a device palette', function (): void { - $user = User::factory()->create(); - $palette = DevicePalette::create([ - 'name' => 'to-delete', - 'grays' => 2, - 'framework_class' => '', - 'source' => 'manual', - ]); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->call('deleteDevicePalette', $palette->id); - - expect(DevicePalette::find($palette->id))->toBeNull(); - $component->assertSet('devicePalettes', function ($palettes) use ($palette) { - return $palettes->where('id', $palette->id)->isEmpty(); - }); -}); - -test('can duplicate a device palette', function (): void { - $user = User::factory()->create(); - $palette = DevicePalette::create([ - 'name' => 'original-palette', - 'description' => 'Original Description', - 'grays' => 4, - 'colors' => ['#FF0000', '#00FF00'], - 'framework_class' => 'Framework', - 'source' => 'manual', - ]); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->call('duplicateDevicePalette', $palette->id); - - $component - ->assertSet('editingDevicePaletteId', null) - ->assertSet('name', 'original-palette (Copy)') - ->assertSet('description', 'Original Description') - ->assertSet('grays', 4) - ->assertSet('colors', ['#FF0000', '#00FF00']) - ->assertSet('framework_class', 'Framework'); -}); - -test('can add a color to the colors array', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('colorInput', '#FF0000') - ->call('addColor'); - - $component - ->assertHasNoErrors() - ->assertSet('colors', ['#FF0000']) - ->assertSet('colorInput', ''); -}); - -test('cannot add duplicate colors', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('colors', ['#FF0000']) - ->set('colorInput', '#FF0000') - ->call('addColor'); - - $component - ->assertHasNoErrors() - ->assertSet('colors', ['#FF0000']); -}); - -test('can add multiple colors', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('colorInput', '#FF0000') - ->call('addColor') - ->set('colorInput', '#00FF00') - ->call('addColor') - ->set('colorInput', '#0000FF') - ->call('addColor'); - - $component - ->assertSet('colors', ['#FF0000', '#00FF00', '#0000FF']); -}); - -test('can remove a color from the colors array', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('colors', ['#FF0000', '#00FF00', '#0000FF']) - ->call('removeColor', 1); - - $component->assertSet('colors', ['#FF0000', '#0000FF']); -}); - -test('removing color reindexes array', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('colors', ['#FF0000', '#00FF00', '#0000FF']) - ->call('removeColor', 0); - - $colors = $component->get('colors'); - expect($colors)->toBe(['#00FF00', '#0000FF']); - expect(array_keys($colors))->toBe([0, 1]); -}); - -test('can open modal in view-only mode for api-sourced palette', function (): void { - $user = User::factory()->create(); - $palette = DevicePalette::create([ - 'name' => 'api-palette', - 'grays' => 2, - 'framework_class' => '', - 'source' => 'api', - ]); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->call('openDevicePaletteModal', $palette->id, true); - - $component - ->assertSet('viewingDevicePaletteId', $palette->id) - ->assertSet('editingDevicePaletteId', null); -}); - -test('name is required when creating device palette', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('grays', 16) - ->call('saveDevicePalette'); - - $component->assertHasErrors(['name']); -}); - -test('name must be unique when creating device palette', function (): void { - $user = User::factory()->create(); - DevicePalette::create([ - 'name' => 'existing-name', - 'grays' => 2, - 'framework_class' => '', - ]); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'existing-name') - ->set('grays', 16) - ->call('saveDevicePalette'); - - $component->assertHasErrors(['name']); -}); - -test('name can be same when updating device palette', function (): void { - $user = User::factory()->create(); - $palette = DevicePalette::create([ - 'name' => 'original-name', - 'grays' => 2, - 'framework_class' => '', - 'source' => 'manual', - ]); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->call('openDevicePaletteModal', $palette->id) - ->set('grays', 16) - ->call('saveDevicePalette'); - - $component->assertHasNoErrors(); -}); - -test('grays is required when creating device palette', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'test-palette') - ->set('grays', null) - ->call('saveDevicePalette'); - - $component->assertHasErrors(['grays']); -}); - -test('grays must be at least 1', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'test-palette') - ->set('grays', 0) - ->call('saveDevicePalette'); - - $component->assertHasErrors(['grays']); -}); - -test('grays must be at most 256', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'test-palette') - ->set('grays', 257) - ->call('saveDevicePalette'); - - $component->assertHasErrors(['grays']); -}); - -test('colors must be valid hex format', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'test-palette') - ->set('grays', 16) - ->set('colors', ['invalid-color', '#FF0000']) - ->call('saveDevicePalette'); - - $component->assertHasErrors(['colors.0']); -}); - -test('color input must be valid hex format when adding color', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('colorInput', 'invalid-color') - ->call('addColor'); - - $component->assertHasErrors(['colorInput']); -}); - -test('color input accepts valid hex format', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('colorInput', '#FF0000') - ->call('addColor'); - - $component->assertHasNoErrors(); -}); - -test('color input accepts lowercase hex format', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('colorInput', '#ff0000') - ->call('addColor'); - - $component->assertHasNoErrors(); -}); - -test('description can be null', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'test-palette') - ->set('grays', 16) - ->set('description', null) - ->call('saveDevicePalette'); - - $component->assertHasNoErrors(); - - $palette = DevicePalette::where('name', 'test-palette')->first(); - expect($palette->description)->toBeNull(); -}); - -test('framework class can be empty string', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'test-palette') - ->set('grays', 16) - ->set('framework_class', '') - ->call('saveDevicePalette'); - - $component->assertHasNoErrors(); - - $palette = DevicePalette::where('name', 'test-palette')->first(); - expect($palette->framework_class)->toBe(''); -}); - -test('empty colors array is saved as null', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'test-palette') - ->set('grays', 16) - ->set('colors', []) - ->call('saveDevicePalette'); - - $component->assertHasNoErrors(); - - $palette = DevicePalette::where('name', 'test-palette')->first(); - expect($palette->colors)->toBeNull(); -}); - -test('component resets form after saving', function (): void { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'test-palette') - ->set('description', 'Test Description') - ->set('grays', 16) - ->set('colors', ['#FF0000']) - ->set('framework_class', 'TestFramework') - ->call('saveDevicePalette'); - - $component - ->assertSet('name', null) - ->assertSet('description', null) - ->assertSet('grays', 2) - ->assertSet('colors', []) - ->assertSet('framework_class', '') - ->assertSet('colorInput', '') - ->assertSet('editingDevicePaletteId', null) - ->assertSet('viewingDevicePaletteId', null); -}); - -test('component handles palette with null colors when editing', function (): void { - $user = User::factory()->create(); - $palette = DevicePalette::create([ - 'name' => 'grayscale-palette', - 'grays' => 2, - 'colors' => null, - 'framework_class' => '', - ]); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->call('openDevicePaletteModal', $palette->id); - - $component->assertSet('colors', []); -}); - -test('component handles palette with string colors when editing', function (): void { - $user = User::factory()->create(); - $palette = DevicePalette::create([ - 'name' => 'string-colors-palette', - 'grays' => 2, - 'framework_class' => '', - ]); - // Manually set colors as JSON string to simulate edge case - $palette->setRawAttributes(array_merge($palette->getAttributes(), [ - 'colors' => json_encode(['#FF0000', '#00FF00']), - ])); - $palette->save(); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->call('openDevicePaletteModal', $palette->id); - - $component->assertSet('colors', ['#FF0000', '#00FF00']); -}); - -test('component refreshes palette list after creating', function (): void { - $user = User::factory()->create(); - $initialCount = DevicePalette::count(); - DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']); - DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->set('name', 'new-palette') - ->set('grays', 16) - ->call('saveDevicePalette'); - - $palettes = $component->get('devicePalettes'); - expect($palettes)->toHaveCount($initialCount + 3); - expect(DevicePalette::count())->toBe($initialCount + 3); -}); - -test('component refreshes palette list after deleting', function (): void { - $user = User::factory()->create(); - $initialCount = DevicePalette::count(); - $palette1 = DevicePalette::create([ - 'name' => 'palette-1', - 'grays' => 2, - 'framework_class' => '', - 'source' => 'manual', - ]); - $palette2 = DevicePalette::create([ - 'name' => 'palette-2', - 'grays' => 2, - 'framework_class' => '', - 'source' => 'manual', - ]); - - $this->actingAs($user); - - $component = Volt::test('device-palettes.index') - ->call('deleteDevicePalette', $palette1->id); - - $palettes = $component->get('devicePalettes'); - expect($palettes)->toHaveCount($initialCount + 1); - expect(DevicePalette::count())->toBe($initialCount + 1); -}); diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php index 963bc0c..44a4f33 100644 --- a/tests/Unit/ExampleTest.php +++ b/tests/Unit/ExampleTest.php @@ -1,5 +1,5 @@ toBeTrue(); }); diff --git a/tests/Unit/Liquid/Filters/DataTest.php b/tests/Unit/Liquid/Filters/DataTest.php index 1200b6f..bdf649f 100644 --- a/tests/Unit/Liquid/Filters/DataTest.php +++ b/tests/Unit/Liquid/Filters/DataTest.php @@ -2,14 +2,14 @@ use App\Liquid\Filters\Data; -test('json filter converts arrays to JSON', function (): void { +test('json filter converts arrays to JSON', function () { $filter = new Data(); $array = ['foo' => 'bar', 'baz' => 'qux']; expect($filter->json($array))->toBe('{"foo":"bar","baz":"qux"}'); }); -test('json filter converts objects to JSON', function (): void { +test('json filter converts objects to JSON', function () { $filter = new Data(); $object = new stdClass(); $object->foo = 'bar'; @@ -18,7 +18,7 @@ test('json filter converts objects to JSON', function (): void { expect($filter->json($object))->toBe('{"foo":"bar","baz":"qux"}'); }); -test('json filter handles nested structures', function (): void { +test('json filter handles nested structures', function () { $filter = new Data(); $nested = [ 'foo' => 'bar', @@ -31,7 +31,7 @@ test('json filter handles nested structures', function (): void { expect($filter->json($nested))->toBe('{"foo":"bar","nested":{"baz":"qux","items":[1,2,3]}}'); }); -test('json filter handles scalar values', function (): void { +test('json filter handles scalar values', function () { $filter = new Data(); expect($filter->json('string'))->toBe('"string"'); @@ -40,21 +40,21 @@ test('json filter handles scalar values', function (): void { expect($filter->json(null))->toBe('null'); }); -test('json filter preserves unicode characters', function (): void { +test('json filter preserves unicode characters', function () { $filter = new Data(); $data = ['message' => 'Hello, 世界']; expect($filter->json($data))->toBe('{"message":"Hello, 世界"}'); }); -test('json filter does not escape slashes', function (): void { +test('json filter does not escape slashes', function () { $filter = new Data(); $data = ['url' => 'https://example.com/path']; expect($filter->json($data))->toBe('{"url":"https://example.com/path"}'); }); -test('find_by filter finds object by key-value pair', function (): void { +test('find_by filter finds object by key-value pair', function () { $filter = new Data(); $collection = [ ['name' => 'Ryan', 'age' => 35], @@ -66,7 +66,7 @@ test('find_by filter finds object by key-value pair', function (): void { expect($result)->toBe(['name' => 'Ryan', 'age' => 35]); }); -test('find_by filter returns null when no match found', function (): void { +test('find_by filter returns null when no match found', function () { $filter = new Data(); $collection = [ ['name' => 'Ryan', 'age' => 35], @@ -78,7 +78,7 @@ test('find_by filter returns null when no match found', function (): void { expect($result)->toBeNull(); }); -test('find_by filter returns fallback when no match found', function (): void { +test('find_by filter returns fallback when no match found', function () { $filter = new Data(); $collection = [ ['name' => 'Ryan', 'age' => 35], @@ -90,7 +90,7 @@ test('find_by filter returns fallback when no match found', function (): void { expect($result)->toBe('Not Found'); }); -test('find_by filter finds by age', function (): void { +test('find_by filter finds by age', function () { $filter = new Data(); $collection = [ ['name' => 'Ryan', 'age' => 35], @@ -102,7 +102,7 @@ test('find_by filter finds by age', function (): void { expect($result)->toBe(['name' => 'Sara', 'age' => 29]); }); -test('find_by filter handles empty collection', function (): void { +test('find_by filter handles empty collection', function () { $filter = new Data(); $collection = []; @@ -110,7 +110,7 @@ test('find_by filter handles empty collection', function (): void { expect($result)->toBeNull(); }); -test('find_by filter handles collection with non-array items', function (): void { +test('find_by filter handles collection with non-array items', function () { $filter = new Data(); $collection = [ 'not an array', @@ -122,7 +122,7 @@ test('find_by filter handles collection with non-array items', function (): void expect($result)->toBe(['name' => 'Ryan', 'age' => 35]); }); -test('find_by filter handles items without the specified key', function (): void { +test('find_by filter handles items without the specified key', function () { $filter = new Data(); $collection = [ ['age' => 35], @@ -134,7 +134,7 @@ test('find_by filter handles items without the specified key', function (): void expect($result)->toBe(['name' => 'Ryan', 'age' => 35]); }); -test('group_by filter groups collection by age', function (): void { +test('group_by filter groups collection by age', function () { $filter = new Data(); $collection = [ ['name' => 'Ryan', 'age' => 35], @@ -153,7 +153,7 @@ test('group_by filter groups collection by age', function (): void { ]); }); -test('group_by filter groups collection by name', function (): void { +test('group_by filter groups collection by name', function () { $filter = new Data(); $collection = [ ['name' => 'Ryan', 'age' => 35], @@ -172,7 +172,7 @@ test('group_by filter groups collection by name', function (): void { ]); }); -test('group_by filter handles empty collection', function (): void { +test('group_by filter handles empty collection', function () { $filter = new Data(); $collection = []; @@ -180,7 +180,7 @@ test('group_by filter handles empty collection', function (): void { expect($result)->toBe([]); }); -test('group_by filter handles collection with non-array items', function (): void { +test('group_by filter handles collection with non-array items', function () { $filter = new Data(); $collection = [ 'not an array', @@ -197,7 +197,7 @@ test('group_by filter handles collection with non-array items', function (): voi ]); }); -test('group_by filter handles items without the specified key', function (): void { +test('group_by filter handles items without the specified key', function () { $filter = new Data(); $collection = [ ['age' => 35], @@ -217,7 +217,7 @@ test('group_by filter handles items without the specified key', function (): voi ]); }); -test('group_by filter handles mixed data types as keys', function (): void { +test('group_by filter handles mixed data types as keys', function () { $filter = new Data(); $collection = [ ['name' => 'Ryan', 'active' => true], @@ -238,7 +238,7 @@ test('group_by filter handles mixed data types as keys', function (): void { ]); }); -test('sample filter returns a random element from array', function (): void { +test('sample filter returns a random element from array', function () { $filter = new Data(); $array = ['1', '2', '3', '4', '5']; @@ -246,7 +246,7 @@ test('sample filter returns a random element from array', function (): void { expect($result)->toBeIn($array); }); -test('sample filter returns a random element from string array', function (): void { +test('sample filter returns a random element from string array', function () { $filter = new Data(); $array = ['cat', 'dog']; @@ -254,7 +254,7 @@ test('sample filter returns a random element from string array', function (): vo expect($result)->toBeIn($array); }); -test('sample filter returns null for empty array', function (): void { +test('sample filter returns null for empty array', function () { $filter = new Data(); $array = []; @@ -262,7 +262,7 @@ test('sample filter returns null for empty array', function (): void { expect($result)->toBeNull(); }); -test('sample filter returns the only element from single element array', function (): void { +test('sample filter returns the only element from single element array', function () { $filter = new Data(); $array = ['single']; @@ -270,7 +270,7 @@ test('sample filter returns the only element from single element array', functio expect($result)->toBe('single'); }); -test('sample filter works with mixed data types', function (): void { +test('sample filter works with mixed data types', function () { $filter = new Data(); $array = [1, 'string', true, null, ['nested']]; @@ -278,7 +278,7 @@ test('sample filter works with mixed data types', function (): void { expect($result)->toBeIn($array); }); -test('parse_json filter parses JSON string to array', function (): void { +test('parse_json filter parses JSON string to array', function () { $filter = new Data(); $jsonString = '[{"a":1,"b":"c"},"d"]'; @@ -286,7 +286,7 @@ test('parse_json filter parses JSON string to array', function (): void { expect($result)->toBe([['a' => 1, 'b' => 'c'], 'd']); }); -test('parse_json filter parses simple JSON object', function (): void { +test('parse_json filter parses simple JSON object', function () { $filter = new Data(); $jsonString = '{"name":"John","age":30,"city":"New York"}'; @@ -294,7 +294,7 @@ test('parse_json filter parses simple JSON object', function (): void { expect($result)->toBe(['name' => 'John', 'age' => 30, 'city' => 'New York']); }); -test('parse_json filter parses JSON array', function (): void { +test('parse_json filter parses JSON array', function () { $filter = new Data(); $jsonString = '["apple","banana","cherry"]'; @@ -302,7 +302,7 @@ test('parse_json filter parses JSON array', function (): void { expect($result)->toBe(['apple', 'banana', 'cherry']); }); -test('parse_json filter parses nested JSON structure', function (): void { +test('parse_json filter parses nested JSON structure', function () { $filter = new Data(); $jsonString = '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}],"total":2}'; @@ -316,7 +316,7 @@ test('parse_json filter parses nested JSON structure', function (): void { ]); }); -test('parse_json filter handles primitive values', function (): void { +test('parse_json filter handles primitive values', function () { $filter = new Data(); expect($filter->parse_json('"hello"'))->toBe('hello'); @@ -325,173 +325,3 @@ test('parse_json filter handles primitive values', function (): void { expect($filter->parse_json('false'))->toBe(false); expect($filter->parse_json('null'))->toBe(null); }); - -test('map_to_i filter converts string numbers to integers', function (): void { - $filter = new Data(); - $input = ['1', '2', '3', '4', '5']; - - expect($filter->map_to_i($input))->toBe([1, 2, 3, 4, 5]); -}); - -test('map_to_i filter handles mixed string numbers', function (): void { - $filter = new Data(); - $input = ['5', '4', '3', '2', '1']; - - expect($filter->map_to_i($input))->toBe([5, 4, 3, 2, 1]); -}); - -test('map_to_i filter handles decimal strings', function (): void { - $filter = new Data(); - $input = ['1.5', '2.7', '3.0']; - - expect($filter->map_to_i($input))->toBe([1, 2, 3]); -}); - -test('map_to_i filter handles empty array', function (): void { - $filter = new Data(); - $input = []; - - expect($filter->map_to_i($input))->toBe([]); -}); - -test('where_exp filter returns string as array when input is string', function (): void { - $filter = new Data(); - $input = 'just a string'; - - expect($filter->where_exp($input, 'la', 'le'))->toBe(['just a string']); -}); - -test('where_exp filter filters numbers with comparison', function (): void { - $filter = new Data(); - $input = [1, 2, 3, 4, 5]; - - expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([3, 4, 5]); -}); - -test('where_exp filter filters numbers with greater than', function (): void { - $filter = new Data(); - $input = [1, 2, 3, 4, 5]; - - expect($filter->where_exp($input, 'n', 'n > 2'))->toBe([3, 4, 5]); -}); - -test('where_exp filter filters numbers with less than', function (): void { - $filter = new Data(); - $input = [1, 2, 3, 4, 5]; - - expect($filter->where_exp($input, 'n', 'n < 4'))->toBe([1, 2, 3]); -}); - -test('where_exp filter filters numbers with equality', function (): void { - $filter = new Data(); - $input = [1, 2, 3, 4, 5]; - - expect($filter->where_exp($input, 'n', 'n == 3'))->toBe([3]); -}); - -test('where_exp filter filters numbers with not equal', function (): void { - $filter = new Data(); - $input = [1, 2, 3, 4, 5]; - - expect($filter->where_exp($input, 'n', 'n != 3'))->toBe([1, 2, 4, 5]); -}); - -test('where_exp filter filters objects by property', function (): void { - $filter = new Data(); - $input = [ - ['name' => 'Alice', 'age' => 25], - ['name' => 'Bob', 'age' => 30], - ['name' => 'Charlie', 'age' => 35], - ]; - - expect($filter->where_exp($input, 'person', 'person.age >= 30'))->toBe([ - ['name' => 'Bob', 'age' => 30], - ['name' => 'Charlie', 'age' => 35], - ]); -}); - -test('where_exp filter filters objects by string property', function (): void { - $filter = new Data(); - $input = [ - ['name' => 'Alice', 'role' => 'admin'], - ['name' => 'Bob', 'role' => 'user'], - ['name' => 'Charlie', 'role' => 'admin'], - ]; - - expect($filter->where_exp($input, 'user', 'user.role == "admin"'))->toBe([ - ['name' => 'Alice', 'role' => 'admin'], - ['name' => 'Charlie', 'role' => 'admin'], - ]); -}); - -test('where_exp filter handles and operator', function (): void { - $filter = new Data(); - $input = [ - ['name' => 'Alice', 'age' => 25, 'active' => true], - ['name' => 'Bob', 'age' => 30, 'active' => false], - ['name' => 'Charlie', 'age' => 35, 'active' => true], - ]; - - expect($filter->where_exp($input, 'person', 'person.age >= 30 and person.active == true'))->toBe([ - ['name' => 'Charlie', 'age' => 35, 'active' => true], - ]); -}); - -test('where_exp filter handles or operator', function (): void { - $filter = new Data(); - $input = [ - ['name' => 'Alice', 'age' => 25, 'role' => 'admin'], - ['name' => 'Bob', 'age' => 30, 'role' => 'user'], - ['name' => 'Charlie', 'age' => 35, 'role' => 'user'], - ]; - - expect($filter->where_exp($input, 'person', 'person.age < 30 or person.role == "admin"'))->toBe([ - ['name' => 'Alice', 'age' => 25, 'role' => 'admin'], - ]); -}); - -test('where_exp filter handles simple boolean expressions', function (): void { - $filter = new Data(); - $input = [ - ['name' => 'Alice', 'active' => true], - ['name' => 'Bob', 'active' => false], - ['name' => 'Charlie', 'active' => true], - ]; - - expect($filter->where_exp($input, 'person', 'person.active'))->toBe([ - ['name' => 'Alice', 'active' => true], - ['name' => 'Charlie', 'active' => true], - ]); -}); - -test('where_exp filter handles empty array', function (): void { - $filter = new Data(); - $input = []; - - expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([]); -}); - -test('where_exp filter handles associative array', function (): void { - $filter = new Data(); - $input = [ - 'a' => 1, - 'b' => 2, - 'c' => 3, - ]; - - expect($filter->where_exp($input, 'n', 'n >= 2'))->toBe([2, 3]); -}); - -test('where_exp filter handles non-array input', function (): void { - $filter = new Data(); - $input = 123; - - expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([]); -}); - -test('where_exp filter handles null input', function (): void { - $filter = new Data(); - $input = null; - - expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([]); -}); diff --git a/tests/Unit/Liquid/Filters/DateTest.php b/tests/Unit/Liquid/Filters/DateTest.php index 7de8949..5813e10 100644 --- a/tests/Unit/Liquid/Filters/DateTest.php +++ b/tests/Unit/Liquid/Filters/DateTest.php @@ -3,92 +3,30 @@ use App\Liquid\Filters\Date; use Carbon\Carbon; -test('days_ago filter returns correct date', function (): void { +test('days_ago filter returns correct date', function () { $filter = new Date(); $threeDaysAgo = Carbon::now()->subDays(3)->toDateString(); expect($filter->days_ago(3))->toBe($threeDaysAgo); }); -test('days_ago filter handles string input', function (): void { +test('days_ago filter handles string input', function () { $filter = new Date(); $fiveDaysAgo = Carbon::now()->subDays(5)->toDateString(); expect($filter->days_ago('5'))->toBe($fiveDaysAgo); }); -test('days_ago filter with zero days returns today', function (): void { +test('days_ago filter with zero days returns today', function () { $filter = new Date(); $today = Carbon::now()->toDateString(); expect($filter->days_ago(0))->toBe($today); }); -test('days_ago filter with large number works correctly', function (): void { +test('days_ago filter with large number works correctly', function () { $filter = new Date(); $hundredDaysAgo = Carbon::now()->subDays(100)->toDateString(); expect($filter->days_ago(100))->toBe($hundredDaysAgo); }); - -test('ordinalize filter formats date with ordinal day', function (): void { - $filter = new Date(); - - expect($filter->ordinalize('2025-10-02', '%A, %B <>, %Y')) - ->toBe('Thursday, October 2nd, 2025'); -}); - -test('ordinalize filter handles datetime string with timezone', function (): void { - $filter = new Date(); - - expect($filter->ordinalize('2025-12-31 16:50:38 -0400', '%A, %b <>')) - ->toBe('Wednesday, Dec 31st'); -}); - -test('ordinalize filter handles different ordinal suffixes', function (): void { - $filter = new Date(); - - // 1st - expect($filter->ordinalize('2025-01-01', '<>')) - ->toBe('1st'); - - // 2nd - expect($filter->ordinalize('2025-01-02', '<>')) - ->toBe('2nd'); - - // 3rd - expect($filter->ordinalize('2025-01-03', '<>')) - ->toBe('3rd'); - - // 4th - expect($filter->ordinalize('2025-01-04', '<>')) - ->toBe('4th'); - - // 11th (special case) - expect($filter->ordinalize('2025-01-11', '<>')) - ->toBe('11th'); - - // 12th (special case) - expect($filter->ordinalize('2025-01-12', '<>')) - ->toBe('12th'); - - // 13th (special case) - expect($filter->ordinalize('2025-01-13', '<>')) - ->toBe('13th'); - - // 21st - expect($filter->ordinalize('2025-01-21', '<>')) - ->toBe('21st'); - - // 22nd - expect($filter->ordinalize('2025-01-22', '<>')) - ->toBe('22nd'); - - // 23rd - expect($filter->ordinalize('2025-01-23', '<>')) - ->toBe('23rd'); - - // 24th - expect($filter->ordinalize('2025-01-24', '<>')) - ->toBe('24th'); -}); diff --git a/tests/Unit/Liquid/Filters/LocalizationTest.php b/tests/Unit/Liquid/Filters/LocalizationTest.php index 3129b1e..384c837 100644 --- a/tests/Unit/Liquid/Filters/LocalizationTest.php +++ b/tests/Unit/Liquid/Filters/LocalizationTest.php @@ -2,7 +2,7 @@ use App\Liquid\Filters\Localization; -test('l_date formats date with default format', function (): void { +test('l_date formats date with default format', function () { $filter = new Localization(); $date = '2025-01-11'; @@ -15,7 +15,7 @@ test('l_date formats date with default format', function (): void { expect($result)->toContain('11'); }); -test('l_date formats date with custom format', function (): void { +test('l_date formats date with custom format', function () { $filter = new Localization(); $date = '2025-01-11'; @@ -27,7 +27,7 @@ test('l_date formats date with custom format', function (): void { // We can't check for 'Jan' specifically as it might be localized }); -test('l_date handles DateTime objects', function (): void { +test('l_date handles DateTime objects', function () { $filter = new Localization(); $date = new DateTimeImmutable('2025-01-11'); @@ -36,102 +36,27 @@ test('l_date handles DateTime objects', function (): void { expect($result)->toContain('2025-01-11'); }); -test('l_word translates common words', function (): void { +test('l_word translates common words', function () { $filter = new Localization(); expect($filter->l_word('today', 'de'))->toBe('heute'); }); -test('l_word returns original word if no translation exists', function (): void { +test('l_word returns original word if no translation exists', function () { $filter = new Localization(); expect($filter->l_word('hello', 'es-ES'))->toBe('hello'); expect($filter->l_word('world', 'ko'))->toBe('world'); }); -test('l_word is case-insensitive', function (): void { +test('l_word is case-insensitive', function () { $filter = new Localization(); expect($filter->l_word('TODAY', 'de'))->toBe('heute'); }); -test('l_word returns original word for unknown locales', function (): void { +test('l_word returns original word for unknown locales', function () { $filter = new Localization(); expect($filter->l_word('today', 'unknown-locale'))->toBe('today'); }); - -test('l_date handles locale parameter', function (): void { - $filter = new Localization(); - $date = '2025-01-11'; - - $result = $filter->l_date($date, 'Y-m-d', 'de'); - - // The result should still contain the date components - expect($result)->toContain('2025'); - expect($result)->toContain('01'); - expect($result)->toContain('11'); -}); - -test('l_date handles null locale parameter', function (): void { - $filter = new Localization(); - $date = '2025-01-11'; - - $result = $filter->l_date($date, 'Y-m-d'); - - // Should work the same as default - expect($result)->toContain('2025'); - expect($result)->toContain('01'); - expect($result)->toContain('11'); -}); - -test('l_date handles different date formats with locale', function (): void { - $filter = new Localization(); - $date = '2025-01-11'; - - $result = $filter->l_date($date, '%B %d, %Y', 'en'); - - // Should contain the month name and date - expect($result)->toContain('2025'); - expect($result)->toContain('11'); -}); - -test('l_date handles DateTimeInterface objects with locale', function (): void { - $filter = new Localization(); - $date = new DateTimeImmutable('2025-01-11'); - - $result = $filter->l_date($date, 'Y-m-d', 'fr'); - - // Should still format correctly - expect($result)->toContain('2025'); - expect($result)->toContain('01'); - expect($result)->toContain('11'); -}); - -test('l_date handles invalid date gracefully', function (): void { - $filter = new Localization(); - $invalidDate = 'invalid-date'; - - // This should throw an exception or return a default value - // The exact behavior depends on Carbon's implementation - expect(fn (): string => $filter->l_date($invalidDate))->toThrow(Exception::class); -}); - -test('l_word handles empty string', function (): void { - $filter = new Localization(); - - expect($filter->l_word('', 'de'))->toBe(''); -}); - -test('l_word handles special characters', function (): void { - $filter = new Localization(); - - // Test with a word that has special characters - expect($filter->l_word('café', 'de'))->toBe('café'); -}); - -test('l_word handles numeric strings', function (): void { - $filter = new Localization(); - - expect($filter->l_word('123', 'de'))->toBe('123'); -}); diff --git a/tests/Unit/Liquid/Filters/NumbersTest.php b/tests/Unit/Liquid/Filters/NumbersTest.php index 42deffb..8ea73bf 100644 --- a/tests/Unit/Liquid/Filters/NumbersTest.php +++ b/tests/Unit/Liquid/Filters/NumbersTest.php @@ -2,7 +2,7 @@ use App\Liquid\Filters\Numbers; -test('number_with_delimiter formats numbers with commas by default', function (): void { +test('number_with_delimiter formats numbers with commas by default', function () { $filter = new Numbers(); expect($filter->number_with_delimiter(1234))->toBe('1,234'); @@ -10,21 +10,21 @@ test('number_with_delimiter formats numbers with commas by default', function () expect($filter->number_with_delimiter(0))->toBe('0'); }); -test('number_with_delimiter handles custom delimiters', function (): void { +test('number_with_delimiter handles custom delimiters', function () { $filter = new Numbers(); expect($filter->number_with_delimiter(1234, '.'))->toBe('1.234'); expect($filter->number_with_delimiter(1000000, ' '))->toBe('1 000 000'); }); -test('number_with_delimiter handles decimal values with custom separators', function (): void { +test('number_with_delimiter handles decimal values with custom separators', function () { $filter = new Numbers(); expect($filter->number_with_delimiter(1234.57, ' ', ','))->toBe('1 234,57'); expect($filter->number_with_delimiter(1234.5, '.', ','))->toBe('1.234,50'); }); -test('number_to_currency formats numbers with dollar sign by default', function (): void { +test('number_to_currency formats numbers with dollar sign by default', function () { $filter = new Numbers(); expect($filter->number_to_currency(1234))->toBe('$1,234'); @@ -32,107 +32,16 @@ test('number_to_currency formats numbers with dollar sign by default', function expect($filter->number_to_currency(0))->toBe('$0'); }); -test('number_to_currency handles custom currency symbols', function (): void { +test('number_to_currency handles custom currency symbols', function () { $filter = new Numbers(); expect($filter->number_to_currency(1234, '£'))->toBe('£1,234'); expect($filter->number_to_currency(152350.69, '€'))->toBe('€152,350.69'); }); -test('number_to_currency handles custom delimiters and separators', function (): void { +test('number_to_currency handles custom delimiters and separators', function () { $filter = new Numbers(); - $result1 = $filter->number_to_currency(1234.57, '£', '.', ','); - $result2 = $filter->number_to_currency(1234.57, '€', ',', '.'); - - expect($result1)->toContain('1.234,57'); - expect($result1)->toContain('£'); - expect($result2)->toContain('1,234.57'); - expect($result2)->toContain('€'); -}); - -test('number_with_delimiter handles string numbers', function (): void { - $filter = new Numbers(); - - expect($filter->number_with_delimiter('1234'))->toBe('1,234'); - expect($filter->number_with_delimiter('1234.56'))->toBe('1,234.56'); -}); - -test('number_with_delimiter handles negative numbers', function (): void { - $filter = new Numbers(); - - expect($filter->number_with_delimiter(-1234))->toBe('-1,234'); - expect($filter->number_with_delimiter(-1234.56))->toBe('-1,234.56'); -}); - -test('number_with_delimiter handles zero', function (): void { - $filter = new Numbers(); - - expect($filter->number_with_delimiter(0))->toBe('0'); - expect($filter->number_with_delimiter(0.0))->toBe('0.00'); -}); - -test('number_with_delimiter handles very small numbers', function (): void { - $filter = new Numbers(); - - expect($filter->number_with_delimiter(0.01))->toBe('0.01'); - expect($filter->number_with_delimiter(0.001))->toBe('0.00'); -}); - -test('number_to_currency handles string numbers', function (): void { - $filter = new Numbers(); - - expect($filter->number_to_currency('1234'))->toBe('$1,234'); - expect($filter->number_to_currency('1234.56'))->toBe('$1,234.56'); -}); - -test('number_to_currency handles negative numbers', function (): void { - $filter = new Numbers(); - - expect($filter->number_to_currency(-1234))->toBe('-$1,234'); - expect($filter->number_to_currency(-1234.56))->toBe('-$1,234.56'); -}); - -test('number_to_currency handles zero', function (): void { - $filter = new Numbers(); - - expect($filter->number_to_currency(0))->toBe('$0'); - expect($filter->number_to_currency(0.0))->toBe('$0.00'); -}); - -test('number_to_currency handles currency code conversion', function (): void { - $filter = new Numbers(); - - expect($filter->number_to_currency(1234, '$'))->toBe('$1,234'); - expect($filter->number_to_currency(1234, '€'))->toBe('€1,234'); - expect($filter->number_to_currency(1234, '£'))->toBe('£1,234'); -}); - -test('number_to_currency handles German locale formatting', function (): void { - $filter = new Numbers(); - - // When delimiter is '.' and separator is ',', it should use German locale - $result = $filter->number_to_currency(1234.56, 'EUR', '.', ','); - expect($result)->toContain('1.234,56'); -}); - -test('number_with_delimiter handles different decimal separators', function (): void { - $filter = new Numbers(); - - expect($filter->number_with_delimiter(1234.56, ',', ','))->toBe('1,234,56'); - expect($filter->number_with_delimiter(1234.56, ' ', ','))->toBe('1 234,56'); -}); - -test('number_to_currency handles very large numbers', function (): void { - $filter = new Numbers(); - - expect($filter->number_to_currency(1000000))->toBe('$1,000,000'); - expect($filter->number_to_currency(1000000.50))->toBe('$1,000,000.50'); -}); - -test('number_with_delimiter handles very large numbers', function (): void { - $filter = new Numbers(); - - expect($filter->number_with_delimiter(1000000))->toBe('1,000,000'); - expect($filter->number_with_delimiter(1000000.50))->toBe('1,000,000.50'); + expect($filter->number_to_currency(1234.57, '£', '.', ','))->toBe('1.234,57 £'); + expect($filter->number_to_currency(1234.57, '€', ',', '.'))->toBe('€1,234.57'); }); diff --git a/tests/Unit/Liquid/Filters/StringMarkupTest.php b/tests/Unit/Liquid/Filters/StringMarkupTest.php index bfd1a07..4021a07 100644 --- a/tests/Unit/Liquid/Filters/StringMarkupTest.php +++ b/tests/Unit/Liquid/Filters/StringMarkupTest.php @@ -2,35 +2,35 @@ use App\Liquid\Filters\StringMarkup; -test('pluralize returns singular form with count 1', function (): void { +test('pluralize returns singular form with count 1', function () { $filter = new StringMarkup(); expect($filter->pluralize('book', 1))->toBe('1 book'); expect($filter->pluralize('person', 1))->toBe('1 person'); }); -test('pluralize returns plural form with count greater than 1', function (): void { +test('pluralize returns plural form with count greater than 1', function () { $filter = new StringMarkup(); expect($filter->pluralize('book', 2))->toBe('2 books'); expect($filter->pluralize('person', 4))->toBe('4 people'); }); -test('pluralize handles irregular plurals correctly', function (): void { +test('pluralize handles irregular plurals correctly', function () { $filter = new StringMarkup(); expect($filter->pluralize('child', 3))->toBe('3 children'); expect($filter->pluralize('sheep', 5))->toBe('5 sheep'); }); -test('pluralize uses default count of 2 when not specified', function (): void { +test('pluralize uses default count of 2 when not specified', function () { $filter = new StringMarkup(); expect($filter->pluralize('book'))->toBe('2 books'); expect($filter->pluralize('person'))->toBe('2 people'); }); -test('markdown_to_html converts basic markdown to HTML', function (): void { +test('markdown_to_html converts basic markdown to HTML', function () { $filter = new StringMarkup(); $markdown = 'This is *italic* and **bold**.'; @@ -42,7 +42,7 @@ test('markdown_to_html converts basic markdown to HTML', function (): void { expect($result)->toContain('bold'); }); -test('markdown_to_html converts links correctly', function (): void { +test('markdown_to_html converts links correctly', function () { $filter = new StringMarkup(); $markdown = 'This is [a link](https://example.com).'; @@ -51,7 +51,7 @@ test('markdown_to_html converts links correctly', function (): void { expect($result)->toContain('a link'); }); -test('markdown_to_html handles fallback when Parsedown is not available', function (): void { +test('markdown_to_html handles fallback when Parsedown is not available', function () { // Create a mock that simulates Parsedown not being available $filter = new class extends StringMarkup { @@ -68,103 +68,23 @@ test('markdown_to_html handles fallback when Parsedown is not available', functi expect($result)->toBe('This is *italic* and [a link](https://example.com).'); }); -test('strip_html removes HTML tags', function (): void { +test('strip_html removes HTML tags', function () { $filter = new StringMarkup(); $html = '

This is bold and italic.

'; expect($filter->strip_html($html))->toBe('This is bold and italic.'); }); -test('strip_html preserves text content', function (): void { +test('strip_html preserves text content', function () { $filter = new StringMarkup(); $html = '
Hello, world!
'; expect($filter->strip_html($html))->toBe('Hello, world!'); }); -test('strip_html handles nested tags', function (): void { +test('strip_html handles nested tags', function () { $filter = new StringMarkup(); $html = '

Paragraph with nested tags.

'; expect($filter->strip_html($html))->toBe('Paragraph with nested tags.'); }); - -test('markdown_to_html handles CommonMarkException gracefully', function (): void { - $filter = new StringMarkup(); - - // Create a mock that throws CommonMarkException - $filter = new class extends StringMarkup - { - public function markdown_to_html(string $markdown): ?string - { - try { - // Simulate CommonMarkException - throw new Exception('Invalid markdown'); - } catch (Exception $e) { - Illuminate\Support\Facades\Log::error('Markdown conversion error: '.$e->getMessage()); - } - - return null; - } - }; - - $result = $filter->markdown_to_html('invalid markdown'); - - expect($result)->toBeNull(); -}); - -test('markdown_to_html handles empty string', function (): void { - $filter = new StringMarkup(); - - $result = $filter->markdown_to_html(''); - - expect($result)->toBe(''); -}); - -test('markdown_to_html handles complex markdown', function (): void { - $filter = new StringMarkup(); - $markdown = "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- List item 1\n- List item 2\n\n[Link](https://example.com)"; - - $result = $filter->markdown_to_html($markdown); - - expect($result)->toContain('

Heading

'); - expect($result)->toContain('bold'); - expect($result)->toContain('italic'); - expect($result)->toContain('
    '); - expect($result)->toContain('
  • List item 1
  • '); - expect($result)->toContain('Link'); -}); - -test('strip_html handles empty string', function (): void { - $filter = new StringMarkup(); - - expect($filter->strip_html(''))->toBe(''); -}); - -test('strip_html handles string without HTML tags', function (): void { - $filter = new StringMarkup(); - $text = 'This is plain text without any HTML tags.'; - - expect($filter->strip_html($text))->toBe($text); -}); - -test('strip_html handles self-closing tags', function (): void { - $filter = new StringMarkup(); - $html = '

    Text with
    line break and


    horizontal rule.

    '; - - expect($filter->strip_html($html))->toBe('Text with line break and horizontal rule.'); -}); - -test('pluralize handles zero count', function (): void { - $filter = new StringMarkup(); - - expect($filter->pluralize('book', 0))->toBe('0 books'); - expect($filter->pluralize('person', 0))->toBe('0 people'); -}); - -test('pluralize handles negative count', function (): void { - $filter = new StringMarkup(); - - expect($filter->pluralize('book', -1))->toBe('-1 book'); - expect($filter->pluralize('person', -5))->toBe('-5 people'); -}); diff --git a/tests/Unit/Liquid/Filters/UniquenessTest.php b/tests/Unit/Liquid/Filters/UniquenessTest.php index 76840e1..291f312 100644 --- a/tests/Unit/Liquid/Filters/UniquenessTest.php +++ b/tests/Unit/Liquid/Filters/UniquenessTest.php @@ -2,7 +2,7 @@ use App\Liquid\Filters\Uniqueness; -test('append_random appends a random string with 4 characters', function (): void { +test('append_random appends a random string with 4 characters', function () { $filter = new Uniqueness(); $result = $filter->append_random('chart-'); diff --git a/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php b/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php deleted file mode 100644 index ee4d2fd..0000000 --- a/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php +++ /dev/null @@ -1,201 +0,0 @@ - 1, 'b' => 2, 'c' => 3]; - - expect(ExpressionUtils::isAssociativeArray($array))->toBeTrue(); -}); - -test('isAssociativeArray returns false for indexed array', function (): void { - $array = [1, 2, 3, 4, 5]; - - expect(ExpressionUtils::isAssociativeArray($array))->toBeFalse(); -}); - -test('isAssociativeArray returns false for empty array', function (): void { - $array = []; - - expect(ExpressionUtils::isAssociativeArray($array))->toBeFalse(); -}); - -test('parseCondition handles simple comparison', function (): void { - $result = ExpressionUtils::parseCondition('n >= 3'); - - expect($result)->toBe([ - 'type' => 'comparison', - 'left' => 'n', - 'operator' => '>=', - 'right' => '3', - ]); -}); - -test('parseCondition handles equality comparison', function (): void { - $result = ExpressionUtils::parseCondition('user.role == "admin"'); - - expect($result)->toBe([ - 'type' => 'comparison', - 'left' => 'user.role', - 'operator' => '==', - 'right' => '"admin"', - ]); -}); - -test('parseCondition handles and operator', function (): void { - $result = ExpressionUtils::parseCondition('user.age >= 30 and user.active == true'); - - expect($result)->toBe([ - 'type' => 'and', - 'left' => [ - 'type' => 'comparison', - 'left' => 'user.age', - 'operator' => '>=', - 'right' => '30', - ], - 'right' => [ - 'type' => 'comparison', - 'left' => 'user.active', - 'operator' => '==', - 'right' => 'true', - ], - ]); -}); - -test('parseCondition handles or operator', function (): void { - $result = ExpressionUtils::parseCondition('user.age < 30 or user.role == "admin"'); - - expect($result)->toBe([ - 'type' => 'or', - 'left' => [ - 'type' => 'comparison', - 'left' => 'user.age', - 'operator' => '<', - 'right' => '30', - ], - 'right' => [ - 'type' => 'comparison', - 'left' => 'user.role', - 'operator' => '==', - 'right' => '"admin"', - ], - ]); -}); - -test('parseCondition handles simple expression', function (): void { - $result = ExpressionUtils::parseCondition('user.active'); - - expect($result)->toBe([ - 'type' => 'simple', - 'expression' => 'user.active', - ]); -}); - -test('evaluateCondition handles comparison with numbers', function (): void { - $condition = ExpressionUtils::parseCondition('n >= 3'); - - expect(ExpressionUtils::evaluateCondition($condition, 'n', 5))->toBeTrue(); - expect(ExpressionUtils::evaluateCondition($condition, 'n', 2))->toBeFalse(); - expect(ExpressionUtils::evaluateCondition($condition, 'n', 3))->toBeTrue(); -}); - -test('evaluateCondition handles comparison with strings', function (): void { - $condition = ExpressionUtils::parseCondition('user.role == "admin"'); - $user = ['role' => 'admin']; - - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue(); - - $user = ['role' => 'user']; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse(); -}); - -test('evaluateCondition handles and operator', function (): void { - $condition = ExpressionUtils::parseCondition('user.age >= 30 and user.active == true'); - $user = ['age' => 35, 'active' => true]; - - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue(); - - $user = ['age' => 25, 'active' => true]; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse(); - - $user = ['age' => 35, 'active' => false]; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse(); -}); - -test('evaluateCondition handles or operator', function (): void { - $condition = ExpressionUtils::parseCondition('user.age < 30 or user.role == "admin"'); - $user = ['age' => 25, 'role' => 'user']; - - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue(); - - $user = ['age' => 35, 'role' => 'admin']; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue(); - - $user = ['age' => 35, 'role' => 'user']; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse(); -}); - -test('evaluateCondition handles simple boolean expression', function (): void { - $condition = ExpressionUtils::parseCondition('user.active'); - $user = ['active' => true]; - - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue(); - - $user = ['active' => false]; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse(); -}); - -test('resolveValue returns object when expression matches variable', function (): void { - $object = ['name' => 'Alice', 'age' => 25]; - - expect(ExpressionUtils::resolveValue('user', 'user', $object))->toBe($object); -}); - -test('resolveValue resolves property access for arrays', function (): void { - $object = ['name' => 'Alice', 'age' => 25]; - - expect(ExpressionUtils::resolveValue('user.name', 'user', $object))->toBe('Alice'); - expect(ExpressionUtils::resolveValue('user.age', 'user', $object))->toBe(25); -}); - -test('resolveValue resolves property access for objects', function (): void { - $object = new stdClass(); - $object->name = 'Alice'; - $object->age = 25; - - expect(ExpressionUtils::resolveValue('user.name', 'user', $object))->toBe('Alice'); - expect(ExpressionUtils::resolveValue('user.age', 'user', $object))->toBe(25); -}); - -test('resolveValue returns null for non-existent properties', function (): void { - $object = ['name' => 'Alice']; - - expect(ExpressionUtils::resolveValue('user.age', 'user', $object))->toBeNull(); -}); - -test('resolveValue parses numeric values', function (): void { - expect(ExpressionUtils::resolveValue('123', 'user', []))->toBe(123); - expect(ExpressionUtils::resolveValue('45.67', 'user', []))->toBe(45.67); -}); - -test('resolveValue parses boolean values', function (): void { - expect(ExpressionUtils::resolveValue('true', 'user', []))->toBeTrue(); - expect(ExpressionUtils::resolveValue('false', 'user', []))->toBeFalse(); - expect(ExpressionUtils::resolveValue('TRUE', 'user', []))->toBeTrue(); - expect(ExpressionUtils::resolveValue('FALSE', 'user', []))->toBeFalse(); -}); - -test('resolveValue parses null value', function (): void { - expect(ExpressionUtils::resolveValue('null', 'user', []))->toBeNull(); - expect(ExpressionUtils::resolveValue('NULL', 'user', []))->toBeNull(); -}); - -test('resolveValue removes quotes from strings', function (): void { - expect(ExpressionUtils::resolveValue('"hello"', 'user', []))->toBe('hello'); - expect(ExpressionUtils::resolveValue("'world'", 'user', []))->toBe('world'); -}); - -test('resolveValue returns expression as-is for unquoted strings', function (): void { - expect(ExpressionUtils::resolveValue('hello', 'user', []))->toBe('hello'); - expect(ExpressionUtils::resolveValue('world', 'user', []))->toBe('world'); -}); diff --git a/tests/Unit/Models/DeviceLogTest.php b/tests/Unit/Models/DeviceLogTest.php index f28f4cd..37e128f 100644 --- a/tests/Unit/Models/DeviceLogTest.php +++ b/tests/Unit/Models/DeviceLogTest.php @@ -6,7 +6,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -test('device log belongs to a device', function (): void { +test('device log belongs to a device', function () { $device = Device::factory()->create(); $log = DeviceLog::factory()->create(['device_id' => $device->id]); @@ -14,7 +14,7 @@ test('device log belongs to a device', function (): void { ->and($log->device->id)->toBe($device->id); }); -test('device log casts log_entry to array', function (): void { +test('device log casts log_entry to array', function () { Device::factory()->create(); $log = DeviceLog::factory()->create([ 'log_entry' => [ @@ -29,7 +29,7 @@ test('device log casts log_entry to array', function (): void { ->and($log->log_entry['level'])->toBe('info'); }); -test('device log casts device_timestamp to datetime', function (): void { +test('device log casts device_timestamp to datetime', function () { Device::factory()->create(); $timestamp = now(); $log = DeviceLog::factory()->create([ @@ -40,7 +40,7 @@ test('device log casts device_timestamp to datetime', function (): void { ->and($log->device_timestamp->timestamp)->toBe($timestamp->timestamp); }); -test('device log factory creates valid data', function (): void { +test('device log factory creates valid data', function () { Device::factory()->create(); $log = DeviceLog::factory()->create(); @@ -50,7 +50,7 @@ test('device log factory creates valid data', function (): void { ->and($log->log_entry)->toHaveKeys(['creation_timestamp', 'device_status_stamp', 'log_id', 'log_message', 'log_codeline', 'log_sourcefile', 'additional_info']); }); -test('device log can be created with minimal required fields', function (): void { +test('device log can be created with minimal required fields', function () { $device = Device::factory()->create(); $log = DeviceLog::create([ 'device_id' => $device->id, diff --git a/tests/Unit/Models/DeviceModelTest.php b/tests/Unit/Models/DeviceModelTest.php deleted file mode 100644 index 8c2b6e9..0000000 --- a/tests/Unit/Models/DeviceModelTest.php +++ /dev/null @@ -1,119 +0,0 @@ -create([ - 'name' => 'Test Model', - 'width' => 800, - 'height' => 480, - 'colors' => 4, - 'bit_depth' => 2, - 'scale_factor' => 1.0, - 'rotation' => 0, - 'offset_x' => 0, - 'offset_y' => 0, - ]); - - expect($deviceModel->name)->toBe('Test Model'); - expect($deviceModel->width)->toBe(800); - expect($deviceModel->height)->toBe(480); - expect($deviceModel->colors)->toBe(4); - expect($deviceModel->bit_depth)->toBe(2); - expect($deviceModel->scale_factor)->toBe(1.0); - expect($deviceModel->rotation)->toBe(0); - expect($deviceModel->offset_x)->toBe(0); - expect($deviceModel->offset_y)->toBe(0); -}); - -test('device model casts attributes correctly', function (): void { - $deviceModel = DeviceModel::factory()->create([ - 'width' => '800', - 'height' => '480', - 'colors' => '4', - 'bit_depth' => '2', - 'scale_factor' => '1.5', - 'rotation' => '90', - 'offset_x' => '10', - 'offset_y' => '20', - ]); - - expect($deviceModel->width)->toBeInt(); - expect($deviceModel->height)->toBeInt(); - expect($deviceModel->colors)->toBeInt(); - expect($deviceModel->bit_depth)->toBeInt(); - expect($deviceModel->scale_factor)->toBeFloat(); - expect($deviceModel->rotation)->toBeInt(); - expect($deviceModel->offset_x)->toBeInt(); - expect($deviceModel->offset_y)->toBeInt(); -}); - -test('get color depth attribute returns correct format for bit depth 2', function (): void { - $deviceModel = DeviceModel::factory()->create(['bit_depth' => 2]); - - expect($deviceModel->getColorDepthAttribute())->toBe('2bit'); -}); - -test('get color depth attribute returns correct format for bit depth 4', function (): void { - $deviceModel = DeviceModel::factory()->create(['bit_depth' => 4]); - - expect($deviceModel->getColorDepthAttribute())->toBe('4bit'); -}); - -test('get color depth attribute returns 4bit for bit depth greater than 4', function (): void { - $deviceModel = DeviceModel::factory()->create(['bit_depth' => 8]); - - expect($deviceModel->getColorDepthAttribute())->toBe('4bit'); -}); - -test('get color depth attribute returns null when bit depth is null', function (): void { - $deviceModel = new DeviceModel(['bit_depth' => null]); - - expect($deviceModel->getColorDepthAttribute())->toBeNull(); -}); - -test('get scale level attribute returns null for width 800 or less', function (): void { - $deviceModel = DeviceModel::factory()->create(['width' => 800]); - - expect($deviceModel->getScaleLevelAttribute())->toBeNull(); -}); - -test('get scale level attribute returns large for width between 801 and 1000', function (): void { - $deviceModel = DeviceModel::factory()->create(['width' => 900]); - - expect($deviceModel->getScaleLevelAttribute())->toBe('large'); -}); - -test('get scale level attribute returns xlarge for width between 1001 and 1400', function (): void { - $deviceModel = DeviceModel::factory()->create(['width' => 1200]); - - expect($deviceModel->getScaleLevelAttribute())->toBe('xlarge'); -}); - -test('get scale level attribute returns xxlarge for width greater than 1400', function (): void { - $deviceModel = DeviceModel::factory()->create(['width' => 1500]); - - expect($deviceModel->getScaleLevelAttribute())->toBe('xxlarge'); -}); - -test('get scale level attribute returns null when width is null', function (): void { - $deviceModel = new DeviceModel(['width' => null]); - - expect($deviceModel->getScaleLevelAttribute())->toBeNull(); -}); - -test('device model factory creates valid data', function (): void { - $deviceModel = DeviceModel::factory()->create(); - - expect($deviceModel->name)->not->toBeEmpty(); - expect($deviceModel->width)->toBeInt(); - expect($deviceModel->height)->toBeInt(); - expect($deviceModel->colors)->toBeInt(); - expect($deviceModel->bit_depth)->toBeInt(); - expect($deviceModel->scale_factor)->toBeFloat(); - expect($deviceModel->rotation)->toBeInt(); - expect($deviceModel->offset_x)->toBeInt(); - expect($deviceModel->offset_y)->toBeInt(); -}); diff --git a/tests/Unit/Models/PlaylistItemTest.php b/tests/Unit/Models/PlaylistItemTest.php index 428a165..6bfe00c 100644 --- a/tests/Unit/Models/PlaylistItemTest.php +++ b/tests/Unit/Models/PlaylistItemTest.php @@ -4,7 +4,7 @@ use App\Models\Playlist; use App\Models\PlaylistItem; use App\Models\Plugin; -test('playlist item belongs to playlist', function (): void { +test('playlist item belongs to playlist', function () { $playlist = Playlist::factory()->create(); $playlistItem = PlaylistItem::factory()->create(['playlist_id' => $playlist->id]); @@ -13,7 +13,7 @@ test('playlist item belongs to playlist', function (): void { ->id->toBe($playlist->id); }); -test('playlist item belongs to plugin', function (): void { +test('playlist item belongs to plugin', function () { $plugin = Plugin::factory()->create(); $playlistItem = PlaylistItem::factory()->create(['plugin_id' => $plugin->id]); @@ -22,7 +22,7 @@ test('playlist item belongs to plugin', function (): void { ->id->toBe($plugin->id); }); -test('playlist item can check if it is a mashup', function (): void { +test('playlist item can check if it is a mashup', function () { $plugin = Plugin::factory()->create(); $regularItem = PlaylistItem::factory()->create([ 'mashup' => null, @@ -44,7 +44,7 @@ test('playlist item can check if it is a mashup', function (): void { ->and($mashupItem->isMashup())->toBeTrue(); }); -test('playlist item can get mashup name', function (): void { +test('playlist item can get mashup name', function () { $plugin1 = Plugin::factory()->create(); $plugin2 = Plugin::factory()->create(); $mashupItem = PlaylistItem::factory()->create([ @@ -59,7 +59,7 @@ test('playlist item can get mashup name', function (): void { expect($mashupItem->getMashupName())->toBe('Test Mashup'); }); -test('playlist item can get mashup layout type', function (): void { +test('playlist item can get mashup layout type', function () { $plugin1 = Plugin::factory()->create(); $plugin2 = Plugin::factory()->create(); $mashupItem = PlaylistItem::factory()->create([ @@ -74,7 +74,7 @@ test('playlist item can get mashup layout type', function (): void { expect($mashupItem->getMashupLayoutType())->toBe('1Lx1R'); }); -test('playlist item can get mashup plugin ids', function (): void { +test('playlist item can get mashup plugin ids', function () { $plugin1 = Plugin::factory()->create(); $plugin2 = Plugin::factory()->create(); $mashupItem = PlaylistItem::factory()->create([ @@ -89,7 +89,7 @@ test('playlist item can get mashup plugin ids', function (): void { expect($mashupItem->getMashupPluginIds())->toBe([$plugin1->id, $plugin2->id]); }); -test('playlist item can get required plugin count for different layouts', function (): void { +test('playlist item can get required plugin count for different layouts', function () { $layouts = [ '1Lx1R' => 2, '1Tx1B' => 2, @@ -117,7 +117,7 @@ test('playlist item can get required plugin count for different layouts', functi } }); -test('playlist item can get layout type', function (): void { +test('playlist item can get layout type', function () { $layoutTypes = [ '1Lx1R' => 'vertical', '1Lx2R' => 'vertical', @@ -144,7 +144,7 @@ test('playlist item can get layout type', function (): void { } }); -test('playlist item can get layout size for different positions', function (): void { +test('playlist item can get layout size for different positions', function () { $plugin1 = Plugin::factory()->create(); $plugin2 = Plugin::factory()->create(); $plugin3 = Plugin::factory()->create(); @@ -163,7 +163,7 @@ test('playlist item can get layout size for different positions', function (): v ->and($mashupItem->getLayoutSize(2))->toBe('half_vertical'); }); -test('playlist item can get available layouts', function (): void { +test('playlist item can get available layouts', function () { $layouts = PlaylistItem::getAvailableLayouts(); expect($layouts)->toBeArray() @@ -171,7 +171,7 @@ test('playlist item can get available layouts', function (): void { ->and($layouts['1Lx1R'])->toBe('1 Left - 1 Right (2 plugins)'); }); -test('playlist item can get required plugin count for layout', function (): void { +test('playlist item can get required plugin count for layout', function () { $layouts = [ '1Lx1R' => 2, '1Tx1B' => 2, @@ -187,7 +187,7 @@ test('playlist item can get required plugin count for layout', function (): void } }); -test('playlist item can create mashup', function (): void { +test('playlist item can create mashup', function () { $playlist = Playlist::factory()->create(); $plugins = Plugin::factory()->count(3)->create(); $pluginIds = $plugins->pluck('id')->toArray(); diff --git a/tests/Unit/Models/PlaylistTest.php b/tests/Unit/Models/PlaylistTest.php index 62d3aaf..55d31c7 100644 --- a/tests/Unit/Models/PlaylistTest.php +++ b/tests/Unit/Models/PlaylistTest.php @@ -4,7 +4,7 @@ use App\Models\Device; use App\Models\Playlist; use App\Models\PlaylistItem; -test('playlist has required attributes', function (): void { +test('playlist has required attributes', function () { $playlist = Playlist::factory()->create([ 'name' => 'Test Playlist', 'is_active' => true, @@ -21,7 +21,7 @@ test('playlist has required attributes', function (): void { ->active_until->format('H:i')->toBe('17:00'); }); -test('playlist belongs to device', function (): void { +test('playlist belongs to device', function () { $device = Device::factory()->create(); $playlist = Playlist::factory()->create(['device_id' => $device->id]); @@ -30,7 +30,7 @@ test('playlist belongs to device', function (): void { ->id->toBe($device->id); }); -test('playlist has many items', function (): void { +test('playlist has many items', function () { $playlist = Playlist::factory()->create(); $items = PlaylistItem::factory()->count(3)->create(['playlist_id' => $playlist->id]); @@ -39,7 +39,7 @@ test('playlist has many items', function (): void { ->each->toBeInstanceOf(PlaylistItem::class); }); -test('getNextPlaylistItem returns null when playlist is inactive', function (): void { +test('getNextPlaylistItem returns null when playlist is inactive', function () { $playlist = Playlist::factory()->create(['is_active' => false]); expect($playlist->getNextPlaylistItem())->toBeNull(); diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index aa9a28e..248e6f5 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -1,13 +1,11 @@ create([ 'name' => 'Test Plugin', 'data_payload' => ['key' => 'value'], @@ -20,7 +18,7 @@ test('plugin has required attributes', function (): void { ->uuid->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/'); }); -test('plugin automatically generates uuid on creation', function (): void { +test('plugin automatically generates uuid on creation', function () { $plugin = Plugin::factory()->create(); expect($plugin->uuid) @@ -28,14 +26,14 @@ test('plugin automatically generates uuid on creation', function (): void { ->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/'); }); -test('plugin can have custom uuid', function (): void { +test('plugin can have custom uuid', function () { $uuid = Illuminate\Support\Str::uuid(); $plugin = Plugin::factory()->create(['uuid' => $uuid]); expect($plugin->uuid)->toBe($uuid); }); -test('plugin data_payload is cast to array', function (): void { +test('plugin data_payload is cast to array', function () { $data = ['key' => 'value']; $plugin = Plugin::factory()->create(['data_payload' => $data]); @@ -44,7 +42,7 @@ test('plugin data_payload is cast to array', function (): void { ->toBe($data); }); -test('plugin can have polling body for POST requests', function (): void { +test('plugin can have polling body for POST requests', function () { $plugin = Plugin::factory()->create([ 'polling_verb' => 'post', 'polling_body' => '{"query": "query { user { id name } }"}', @@ -53,7 +51,7 @@ test('plugin can have polling body for POST requests', function (): void { expect($plugin->polling_body)->toBe('{"query": "query { user { id name } }"}'); }); -test('updateDataPayload sends POST request with body when polling_verb is post', function (): void { +test('updateDataPayload sends POST request with body when polling_verb is post', function () { Http::fake([ 'https://example.com/api' => Http::response(['success' => true], 200), ]); @@ -67,12 +65,14 @@ test('updateDataPayload sends POST request with body when polling_verb is post', $plugin->updateDataPayload(); - Http::assertSent(fn ($request): bool => $request->url() === 'https://example.com/api' && - $request->method() === 'POST' && - $request->body() === '{"query": "query { user { id name } }"}'); + Http::assertSent(function ($request) { + return $request->url() === 'https://example.com/api' && + $request->method() === 'POST' && + $request->body() === '{"query": "query { user { id name } }"}'; + }); }); -test('updateDataPayload handles multiple URLs with IDX_ prefixes', function (): void { +test('updateDataPayload handles multiple URLs with IDX_ prefixes', function () { $plugin = Plugin::factory()->create([ 'data_strategy' => 'polling', 'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/weather\nhttps://api3.example.com/news", @@ -99,36 +99,7 @@ test('updateDataPayload handles multiple URLs with IDX_ prefixes', function (): expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']); }); -test('updateDataPayload skips empty lines in polling_url and maintains sequential IDX keys', function (): void { - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - // empty lines and extra spaces between the URL to generate empty entries - 'polling_url' => "https://api1.example.com/data\n \n\nhttps://api2.example.com/weather\n ", - 'polling_verb' => 'get', - ]); - - // Mock only the valid URLs - Http::fake([ - 'https://api1.example.com/data' => Http::response(['item' => 'first'], 200), - 'https://api2.example.com/weather' => Http::response(['item' => 'second'], 200), - ]); - - $plugin->updateDataPayload(); - - // payload should only have 2 items, and they should be indexed 0 and 1 - expect($plugin->data_payload)->toHaveCount(2); - expect($plugin->data_payload)->toHaveKey('IDX_0'); - expect($plugin->data_payload)->toHaveKey('IDX_1'); - - // data is correct - expect($plugin->data_payload['IDX_0'])->toBe(['item' => 'first']); - expect($plugin->data_payload['IDX_1'])->toBe(['item' => 'second']); - - // no empty index exists - expect($plugin->data_payload)->not->toHaveKey('IDX_2'); -}); - -test('updateDataPayload handles single URL without nesting', function (): void { +test('updateDataPayload handles single URL without nesting', function () { $plugin = Plugin::factory()->create([ 'data_strategy' => 'polling', 'polling_url' => 'https://api.example.com/data', @@ -149,7 +120,7 @@ test('updateDataPayload handles single URL without nesting', function (): void { expect($plugin->data_payload)->not->toHaveKey('IDX_0'); }); -test('updateDataPayload resolves Liquid variables in polling_header', function (): void { +test('updateDataPayload resolves Liquid variables in polling_header', function () { $plugin = Plugin::factory()->create([ 'data_strategy' => 'polling', 'polling_url' => 'https://api.example.com/data', @@ -168,13 +139,15 @@ test('updateDataPayload resolves Liquid variables in polling_header', function ( $plugin->updateDataPayload(); - Http::assertSent(fn ($request): bool => $request->url() === 'https://api.example.com/data' && - $request->method() === 'GET' && - $request->header('Authorization')[0] === 'Bearer test123' && - $request->header('X-Custom-Header')[0] === 'custom_header_value'); + Http::assertSent(function ($request) { + return $request->url() === 'https://api.example.com/data' && + $request->method() === 'GET' && + $request->header('Authorization')[0] === 'Bearer test123' && + $request->header('X-Custom-Header')[0] === 'custom_header_value'; + }); }); -test('updateDataPayload resolves Liquid variables in polling_body', function (): void { +test('updateDataPayload resolves Liquid variables in polling_body', function () { $plugin = Plugin::factory()->create([ 'data_strategy' => 'polling', 'polling_url' => 'https://api.example.com/data', @@ -193,7 +166,7 @@ test('updateDataPayload resolves Liquid variables in polling_body', function (): $plugin->updateDataPayload(); - Http::assertSent(function ($request): bool { + Http::assertSent(function ($request) { $expectedBody = '{"query": "query { user { id name } }", "api_key": "test123", "user_id": "456"}'; return $request->url() === 'https://api.example.com/data' && @@ -202,7 +175,7 @@ test('updateDataPayload resolves Liquid variables in polling_body', function (): }); }); -test('webhook plugin is stale if webhook event occurred', function (): void { +test('webhook plugin is stale if webhook event occurred', function () { $plugin = Plugin::factory()->create([ 'data_strategy' => 'webhook', 'data_payload_updated_at' => now()->subMinutes(10), @@ -213,7 +186,7 @@ test('webhook plugin is stale if webhook event occurred', function (): void { }); -test('webhook plugin data not stale if no webhook event occurred for 1 hour', function (): void { +test('webhook plugin data not stale if no webhook event occurred for 1 hour', function () { $plugin = Plugin::factory()->create([ 'data_strategy' => 'webhook', 'data_payload_updated_at' => now()->subMinutes(60), @@ -224,7 +197,7 @@ test('webhook plugin data not stale if no webhook event occurred for 1 hour', fu }); -test('plugin configuration is cast to array', function (): void { +test('plugin configuration is cast to array', function () { $config = ['timezone' => 'UTC', 'refresh_interval' => 30]; $plugin = Plugin::factory()->create(['configuration' => $config]); @@ -233,7 +206,7 @@ test('plugin configuration is cast to array', function (): void { ->toBe($config); }); -test('plugin can get configuration value by key', function (): void { +test('plugin can get configuration value by key', function () { $config = ['timezone' => 'UTC', 'refresh_interval' => 30]; $plugin = Plugin::factory()->create(['configuration' => $config]); @@ -242,7 +215,7 @@ test('plugin can get configuration value by key', function (): void { expect($plugin->getConfiguration('nonexistent', 'default'))->toBe('default'); }); -test('plugin configuration template is cast to array', function (): void { +test('plugin configuration template is cast to array', function () { $template = [ 'custom_fields' => [ [ @@ -260,7 +233,7 @@ test('plugin configuration template is cast to array', function (): void { ->toBe($template); }); -test('resolveLiquidVariables resolves variables from configuration', function (): void { +test('resolveLiquidVariables resolves variables from configuration', function () { $plugin = Plugin::factory()->create([ 'configuration' => [ 'api_key' => '12345', @@ -290,7 +263,7 @@ test('resolveLiquidVariables resolves variables from configuration', function () expect($result)->toBe('High'); }); -test('resolveLiquidVariables handles invalid Liquid syntax gracefully', function (): void { +test('resolveLiquidVariables handles invalid Liquid syntax gracefully', function () { $plugin = Plugin::factory()->create([ 'configuration' => [ 'api_key' => '12345', @@ -304,7 +277,7 @@ test('resolveLiquidVariables handles invalid Liquid syntax gracefully', function ->toThrow(Keepsuit\Liquid\Exceptions\SyntaxException::class); }); -test('plugin can extract default values from custom fields configuration template', function (): void { +test('plugin can extract default values from custom fields configuration template', function () { $configurationTemplate = [ 'custom_fields' => [ [ @@ -350,7 +323,7 @@ test('plugin can extract default values from custom fields configuration templat expect($plugin->getConfiguration('timezone'))->toBeNull(); }); -test('resolveLiquidVariables resolves configuration variables correctly', function (): void { +test('resolveLiquidVariables resolves configuration variables correctly', function () { $plugin = Plugin::factory()->create([ 'configuration' => [ 'Latitude' => '48.2083', @@ -365,7 +338,7 @@ test('resolveLiquidVariables resolves configuration variables correctly', functi expect($plugin->resolveLiquidVariables($template))->toBe($expected); }); -test('resolveLiquidVariables handles missing variables gracefully', function (): void { +test('resolveLiquidVariables handles missing variables gracefully', function () { $plugin = Plugin::factory()->create([ 'configuration' => [ 'Latitude' => '48.2083', @@ -378,7 +351,7 @@ test('resolveLiquidVariables handles missing variables gracefully', function (): expect($plugin->resolveLiquidVariables($template))->toBe($expected); }); -test('resolveLiquidVariables handles empty configuration', function (): void { +test('resolveLiquidVariables handles empty configuration', function () { $plugin = Plugin::factory()->create([ 'configuration' => [], ]); @@ -388,553 +361,3 @@ test('resolveLiquidVariables handles empty configuration', function (): void { expect($plugin->resolveLiquidVariables($template))->toBe($expected); }); - -test('resolveLiquidVariables uses external renderer when preferred_renderer is trmnl-liquid and template contains for loop', function (): void { - Illuminate\Support\Facades\Process::fake([ - '*' => Illuminate\Support\Facades\Process::result( - output: 'https://api1.example.com/data\nhttps://api2.example.com/data', - exitCode: 0 - ), - ]); - - config(['services.trmnl.liquid_enabled' => true]); - config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']); - - $plugin = Plugin::factory()->create([ - 'preferred_renderer' => 'trmnl-liquid', - 'configuration' => [ - 'recipe_ids' => '1,2', - ], - ]); - - $template = <<<'LIQUID' -{% assign ids = recipe_ids | split: "," %} -{% for id in ids %} -https://api{{ id }}.example.com/data -{% endfor %} -LIQUID; - - $result = $plugin->resolveLiquidVariables($template); - - // Trim trailing newlines that may be added by the process - expect(mb_trim($result))->toBe('https://api1.example.com/data\nhttps://api2.example.com/data'); - - Illuminate\Support\Facades\Process::assertRan(function ($process): bool { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; - - return str_contains($command, 'trmnl-liquid-cli') && - str_contains($command, '--template') && - str_contains($command, '--context'); - }); -}); - -test('resolveLiquidVariables uses internal renderer when preferred_renderer is not trmnl-liquid', function (): void { - $plugin = Plugin::factory()->create([ - 'preferred_renderer' => 'php', - 'configuration' => [ - 'recipe_ids' => '1,2', - ], - ]); - - $template = <<<'LIQUID' -{% assign ids = recipe_ids | split: "," %} -{% for id in ids %} -https://api{{ id }}.example.com/data -{% endfor %} -LIQUID; - - // Should use internal renderer even with for loop - $result = $plugin->resolveLiquidVariables($template); - - // Internal renderer should process the template - expect($result)->toBeString(); -}); - -test('resolveLiquidVariables uses internal renderer when external renderer is disabled', function (): void { - config(['services.trmnl.liquid_enabled' => false]); - - $plugin = Plugin::factory()->create([ - 'preferred_renderer' => 'trmnl-liquid', - 'configuration' => [ - 'recipe_ids' => '1,2', - ], - ]); - - $template = <<<'LIQUID' -{% assign ids = recipe_ids | split: "," %} -{% for id in ids %} -https://api{{ id }}.example.com/data -{% endfor %} -LIQUID; - - // Should use internal renderer when external is disabled - $result = $plugin->resolveLiquidVariables($template); - - expect($result)->toBeString(); -}); - -test('resolveLiquidVariables uses internal renderer when template does not contain for loop', function (): void { - config(['services.trmnl.liquid_enabled' => true]); - config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']); - - $plugin = Plugin::factory()->create([ - 'preferred_renderer' => 'trmnl-liquid', - 'configuration' => [ - 'api_key' => 'test123', - ], - ]); - - $template = 'https://api.example.com/data?key={{ api_key }}'; - - // Should use internal renderer when no for loop - $result = $plugin->resolveLiquidVariables($template); - - expect($result)->toBe('https://api.example.com/data?key=test123'); - - Illuminate\Support\Facades\Process::assertNothingRan(); -}); - -test('resolveLiquidVariables detects for loop with standard opening tag', function (): void { - Illuminate\Support\Facades\Process::fake([ - '*' => Illuminate\Support\Facades\Process::result( - output: 'resolved', - exitCode: 0 - ), - ]); - - config(['services.trmnl.liquid_enabled' => true]); - config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']); - - $plugin = Plugin::factory()->create([ - 'preferred_renderer' => 'trmnl-liquid', - 'configuration' => [], - ]); - - // Test {% for pattern - $template = '{% for item in items %}test{% endfor %}'; - $plugin->resolveLiquidVariables($template); - - Illuminate\Support\Facades\Process::assertRan(function ($process): bool { - $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; - - return str_contains($command, 'trmnl-liquid-cli'); - }); -}); - -test('resolveLiquidVariables detects for loop with whitespace stripping tag', function (): void { - Illuminate\Support\Facades\Process::fake([ - '*' => Illuminate\Support\Facades\Process::result( - output: 'resolved', - exitCode: 0 - ), - ]); - - config(['services.trmnl.liquid_enabled' => true]); - config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']); - - $plugin = Plugin::factory()->create([ - 'preferred_renderer' => 'trmnl-liquid', - 'configuration' => [], - ]); - - // Test {%- for pattern (with whitespace stripping) - $template = '{%- for item in items %}test{% endfor %}'; - $plugin->resolveLiquidVariables($template); - - Illuminate\Support\Facades\Process::assertRan(function ($process): bool { - $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; - - return str_contains($command, 'trmnl-liquid-cli'); - }); -}); - -test('updateDataPayload resolves entire polling_url field first then splits by newline', function (): void { - Http::fake([ - 'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200), - 'https://api2.example.com/data' => Http::response(['data' => 'test2'], 200), - ]); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/data", - 'polling_verb' => 'get', - 'configuration' => [ - 'recipe_ids' => '1,2', - ], - ]); - - $plugin->updateDataPayload(); - - // Should have split the multi-line URL and generated two requests - expect($plugin->data_payload)->toHaveKey('IDX_0'); - expect($plugin->data_payload)->toHaveKey('IDX_1'); - expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']); - expect($plugin->data_payload['IDX_1'])->toBe(['data' => 'test2']); -}); - -test('updateDataPayload handles multi-line polling_url with for loop using external renderer', function (): void { - Illuminate\Support\Facades\Process::fake([ - '*' => Illuminate\Support\Facades\Process::result( - output: "https://api1.example.com/data\nhttps://api2.example.com/data", - exitCode: 0 - ), - ]); - - Http::fake([ - 'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200), - 'https://api2.example.com/data' => Http::response(['data' => 'test2'], 200), - ]); - - config(['services.trmnl.liquid_enabled' => true]); - config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']); - - $plugin = Plugin::factory()->create([ - 'data_strategy' => 'polling', - 'preferred_renderer' => 'trmnl-liquid', - 'polling_url' => <<<'LIQUID' -{% assign ids = recipe_ids | split: "," %} -{% for id in ids %} -https://api{{ id }}.example.com/data -{% endfor %} -LIQUID - , - 'polling_verb' => 'get', - 'configuration' => [ - 'recipe_ids' => '1,2', - ], - ]); - - $plugin->updateDataPayload(); - - // Should have used external renderer and generated two URLs - expect($plugin->data_payload)->toHaveKey('IDX_0'); - expect($plugin->data_payload)->toHaveKey('IDX_1'); - expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']); - expect($plugin->data_payload['IDX_1'])->toBe(['data' => 'test2']); - - Illuminate\Support\Facades\Process::assertRan(function ($process): bool { - $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; - - return str_contains($command, 'trmnl-liquid-cli'); - }); -}); - -test('plugin render uses user timezone when set', function (): void { - $user = User::factory()->create([ - 'timezone' => 'America/New_York', - ]); - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'markup_language' => 'liquid', - 'render_markup' => '{{ trmnl.user.time_zone_iana }}', - ]); - - $rendered = $plugin->render(); - - expect($rendered)->toContain('America/New_York'); -}); - -test('plugin render falls back to app timezone when user timezone is not set', function (): void { - $user = User::factory()->create([ - 'timezone' => null, - ]); - - config(['app.timezone' => 'Europe/London']); - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'markup_language' => 'liquid', - 'render_markup' => '{{ trmnl.user.time_zone_iana }}', - ]); - - $rendered = $plugin->render(); - - expect($rendered)->toContain('Europe/London'); -}); - -test('plugin render calculates correct UTC offset from user timezone', function (): void { - $user = User::factory()->create([ - 'timezone' => 'America/New_York', // UTC-5 (EST) or UTC-4 (EDT) - ]); - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'markup_language' => 'liquid', - 'render_markup' => '{{ trmnl.user.utc_offset }}', - ]); - - $rendered = $plugin->render(); - - // America/New_York offset should be -18000 (EST) or -14400 (EDT) in seconds - $expectedOffset = (string) Carbon::now('America/New_York')->getOffset(); - expect($rendered)->toContain($expectedOffset); -}); - -test('plugin render calculates correct UTC offset from app timezone when user timezone is null', function (): void { - $user = User::factory()->create([ - 'timezone' => null, - ]); - - config(['app.timezone' => 'Europe/London']); - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'markup_language' => 'liquid', - 'render_markup' => '{{ trmnl.user.utc_offset }}', - ]); - - $rendered = $plugin->render(); - - // Europe/London offset should be 0 (GMT) or 3600 (BST) in seconds - $expectedOffset = (string) Carbon::now('Europe/London')->getOffset(); - expect($rendered)->toContain($expectedOffset); -}); - -test('plugin render includes utc_offset and time_zone_iana in trmnl.user context', function (): void { - $user = User::factory()->create([ - 'timezone' => 'America/Chicago', // UTC-6 (CST) or UTC-5 (CDT) - ]); - - $plugin = Plugin::factory()->create([ - 'user_id' => $user->id, - 'markup_language' => 'liquid', - 'render_markup' => '{{ trmnl.user.time_zone_iana }}|{{ trmnl.user.utc_offset }}', - ]); - - $rendered = $plugin->render(); - - expect($rendered) - ->toContain('America/Chicago') - ->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds) -}); - -/** - * Plugin security: XSS Payload Dataset - * [Input, Expected Result, Forbidden String] - */ -dataset('xss_vectors', [ - 'standard_script' => ['Safe ', 'Safe ', '