diff --git a/.devcontainer/cli/Dockerfile b/.devcontainer/cli/Dockerfile new file mode 100644 index 0000000..ab13330 --- /dev/null +++ b/.devcontainer/cli/Dockerfile @@ -0,0 +1,37 @@ +# From official php image. +FROM php:8.4-cli-alpine +# Create a user group and account under id 1000. +RUN addgroup -g 1000 -S user && adduser -u 1000 -D user -G user +# Install quality-of-life packages. +RUN apk add --no-cache bash curl git vim +# Install composer for php deps. +RUN apk add --no-cache composer +# Add Chromium and Image Magick for puppeteer. +RUN apk add --no-cache \ + imagemagick-dev \ + chromium \ + libzip-dev + +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium +ENV PUPPETEER_DOCKER=1 + +RUN mkdir -p /usr/src/php/ext/imagick +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 + +# Composer uses its php binary, but we want it to use the container's one +RUN rm -f /usr/bin/php84 +RUN ln -s /usr/local/bin/php /usr/bin/php84 +# Install postgres pdo driver. +# RUN apk add --no-cache postgresql-dev && docker-php-ext-install pdo_pgsql +# Install redis driver. +# RUN mkdir -p /usr/src/php/ext/redis; \ +# curl -fsSL --ipv4 https://github.com/phpredis/phpredis/archive/6.0.2.tar.gz | tar xvz -C "/usr/src/php/ext/redis" --strip 1; \ +# docker-php-ext-install redis +# Install nodejs and npm for frontend. +RUN apk add --no-cache nodejs npm +# Prevent container from exiting early. +CMD ["sleep", "infinity"] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..326702b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +{ + "name": "LaravelServer", + "service": "cli", + "remoteUser": "user", + "shutdownAction": "stopCompose", + "workspaceFolder": "/workspaces/laravel", + "dockerComposeFile": "docker-compose.yaml", + "forwardPorts": [ "nginx:80" ], + "postCreateCommand": "composer install && npm install && npm run build", + "customizations": { + "vscode": { + "extensions": [ + "bmewburn.vscode-intelephense-client", + "EditorConfig.EditorConfig", + "mikestead.dotenv", + "onecentlin.laravel-blade", + "laravel.vscode-laravel" + ] + } + } +} diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml new file mode 100644 index 0000000..74d389d --- /dev/null +++ b/.devcontainer/docker-compose.yaml @@ -0,0 +1,21 @@ +version: "3.9" +services: + cli: + build: cli + volumes: + - ..:/workspaces/laravel + + fpm: + build: fpm + volumes: + - ..:/workspaces/laravel + user: 1000:1000 + + nginx: + build: nginx + volumes: + - ..:/workspaces/laravel + ports: + - 8080:80 + depends_on: + - fpm diff --git a/.devcontainer/fpm/Dockerfile b/.devcontainer/fpm/Dockerfile new file mode 100644 index 0000000..3e658b6 --- /dev/null +++ b/.devcontainer/fpm/Dockerfile @@ -0,0 +1,31 @@ +# From official php image. +FROM php:8.4-fpm-alpine +RUN addgroup -g 1000 -S user && adduser -u 1000 -D user -G user +# Install postgres pdo driver. +# RUN apk add --no-cache postgresql-dev && docker-php-ext-install pdo_pgsql +# Install redis driver. +# RUN mkdir -p /usr/src/php/ext/redis; \ +# curl -fsSL --ipv4 https://github.com/phpredis/phpredis/archive/6.0.2.tar.gz | tar xvz -C "/usr/src/php/ext/redis" --strip 1; \ +# docker-php-ext-install redis + +RUN apk add --no-cache \ + curl \ + git \ + nodejs \ + npm \ + imagemagick-dev \ + chromium \ + libzip-dev + +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium +ENV PUPPETEER_DOCKER=1 + +RUN mkdir -p /usr/src/php/ext/imagick +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 rm -f /usr/bin/php84 +RUN ln -s /usr/local/bin/php /usr/bin/php84 diff --git a/.devcontainer/license.md b/.devcontainer/license.md new file mode 100644 index 0000000..cfcc016 --- /dev/null +++ b/.devcontainer/license.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 Theodore Messinezis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/.devcontainer/nginx/Dockerfile b/.devcontainer/nginx/Dockerfile new file mode 100644 index 0000000..7ab8af1 --- /dev/null +++ b/.devcontainer/nginx/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:1.27-alpine +COPY default.conf /etc/nginx/conf.d/default.conf diff --git a/.devcontainer/nginx/default.conf b/.devcontainer/nginx/default.conf new file mode 100644 index 0000000..790edd0 --- /dev/null +++ b/.devcontainer/nginx/default.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name localhost; + root /workspaces/laravel/public; + + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + + index index.php; + + charset utf-8; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + error_page 404 /index.php; + + location ~ \.php$ { + fastcgi_pass fpm:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param PHP_VALUE "error_log=/var/log/nginx/php_errors.log"; + fastcgi_buffers 16 16k; + fastcgi_buffer_size 32k; + include fastcgi_params; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} diff --git a/.devcontainer/readme.md b/.devcontainer/readme.md new file mode 100644 index 0000000..42bd3ab --- /dev/null +++ b/.devcontainer/readme.md @@ -0,0 +1,71 @@ +### Laravel Development Containers for Visual Studio Code + +`laravel-devcontainer` is a simple configuration to support fully-dockerised development of Laravel applications using Visual Studio Code. +Unlike Laravel Sail, `laravel-devcontainer` has been built so that the entire development experience is dockerised. The only requirements are: + +- [Visual Studio Code](https://code.visualstudio.com/) +- [Visual Studio Code Remote Containers Extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +- [Docker](https://docs.docker.com/get-docker/) +- [Docker Compose](https://docs.docker.com/compose/install/) + +Visual Studio Code will actually run inside a Docker container with php-cli as well as other development tools. +Any Extensions will also run in the same container, meaning that intellisense will use the same php-cli configuration! + +`laravel-devcontainer` currently ships with: + +- `php:8.3-cli-alpine` workspace with composer, pgsql, redis, and nodejs. +- `php:8.3-fpm-alpine` container with pgsql and redis extensions. +- `nginx:1.27-alpine` preconfigured for your Laravel application. +- `postgres:16.3-alpine` preconfigured with the default Laravel credentials. +- `redis:7.2-alpine` for caching, queues, sessions, etc. + +#### Easy Installation + +Using this configuration is quite simple. [Download](https://github.com/theomessin/laravel-devcontainer/archive/refs/heads/master.zip) and place `laravel-devcontainer` in a `.devcontainer` folder with your Laravel Code. If starting a new project, you may create a new folder with just `laravel-devcontainer` in your `.devcontainer` folder. You may then [install Laravel using Composer](https://laravel.com/docs/11.x/installation#creating-a-laravel-project) (e.g. under `example-app`). You may then move the `.devcontainer` folder to your code folder (`mv .devcontainer example-app`) and use that! + +#### Installing Using Git Submodules + +Alternatively, you may use [Git Submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules). Install the configuration by running + +```sh +git submodule add https://github.com/theomessin/laravel-devcontainer .devcontainer +``` + +If you use this method, do not forget to install submodules when cloning: + +``` +git clone --recurse-submodules ... +``` + +#### Usage + +Start Visual Studio Code (e.g. `code example-app`) and re-open in remote containers (`Remote-Containers: Reopen in Container`). This may take some time on the first use, as Docker initially downloads and builds the images. Eventually, Visual Studio Code will run inside the workspace container. Extensions and settings specified in `devcontainer.json` will be auto-configured! + +Be sure to correctly configure your application `.env` to use the devcontainer postgres and redis. For example: + +```env +DB_CONNECTION=pgsql +DB_HOST=postgres +DB_PORT=5432 +DB_DATABASE=laravel +DB_USERNAME=root +DB_PASSWORD= + +REDIS_HOST=redis +REDIS_PASSWORD=null +REDIS_PORT=6379 +``` + +You may then navigate to [`localhost`](http://localhost) on your local machine. Fingers crossed, you will see your Laravel application! +Run any artisan or composer commands using the Visual Studio Code [Integrated Terminal](https://code.visualstudio.com/docs/editor/integrated-terminal). +As such, you do not need anything else installed on your host machine! + +#### Extensions + +`laravel-devcontainer` currently ships with the following extensions for Laravel development in Visual Studio Code: + +- ["bmewburn.vscode-intelephense-client"](https://marketplace.visualstudio.com/items?itemName=bmewburn.vscode-intelephense-client) +- ["eamodio.gitlens"](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens) +- ["EditorConfig.EditorConfig"](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) +- ["mikestead.dotenv"](https://marketplace.visualstudio.com/items?itemName=mikestead.dotenv) +- ["onecentlin.laravel-blade"](https://marketplace.visualstudio.com/items?itemName=onecentlin.laravel-blade) diff --git a/.dockerignore b/.dockerignore index 32d49fe..9de2cf4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,8 @@ +/.devcontainer +/.github /.phpunit.cache +/database/*.sqlite +/bootstrap/cache/* /node_modules /public/build /public/hot @@ -6,6 +10,7 @@ /storage/*.key /storage/pail /vendor +.editorconfig .env .env.backup .env.production @@ -20,5 +25,3 @@ yarn-error.log /.idea /.vscode /.zed -/bootstrap/cache/* -/database/database.sqlite diff --git a/.env.example b/.env.example index 227ccb4..7d64dce 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ PHP_CLI_SERVER_WORKERS=4 BCRYPT_ROUNDS=12 LOG_CHANNEL=stack -LOG_STACK=single,stderr +LOG_STACK=single,stderr,stdout LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug @@ -68,3 +68,10 @@ VITE_APP_NAME="${APP_NAME}" TRMNL_PROXY_BASE_URL=https://trmnl.app TRMNL_PROXY_REFRESH_MINUTES=15 REGISTRATION_ENABLED=1 + +PUPPETEER_MODE= +SIDECAR_ACCESS_KEY_ID= +SIDECAR_SECRET_ACCESS_KEY= +SIDECAR_REGION= +SIDECAR_ARTIFACT_BUCKET_NAME= +SIDECAR_EXECUTION_ROLE= diff --git a/.env.example.local b/.env.example.local new file mode 100644 index 0000000..973f495 --- /dev/null +++ b/.env.example.local @@ -0,0 +1,70 @@ +APP_NAME=TrmnlServer +APP_ENV=local +APP_KEY=base64:zzPXBQPlgn0NHwVBTVG0B//8P/PVwVnBp2gk0ZWR0+k= +APP_DEBUG=true +APP_TIMEZONE=UTC +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single,stderr,stdout +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" + +TRMNL_PROXY_BASE_URL=https://trmnl.app +TRMNL_PROXY_REFRESH_MINUTES=15 +REGISTRATION_ENABLED=1 diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..a4ff129 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,59 @@ +name: Build and Push Docker Images + +on: + release: + types: [published] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Extract version from tag + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + target: production + build-args: | + APP_VERSION=${{ env.VERSION }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 46262d8..78e4fbb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ on: jobs: ci: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 environment: Testing steps: @@ -23,7 +23,6 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: 8.4 - tools: composer:v2 coverage: xdebug - name: Setup Node @@ -32,9 +31,6 @@ jobs: node-version: '22' cache: 'npm' - - name: Install Node Dependencies - run: npm i - - name: Install Dependencies run: composer install --no-interaction --prefer-dist --optimize-autoloader @@ -45,7 +41,9 @@ jobs: run: php artisan key:generate - name: Build Assets - run: npm run build + run: | + npm ci --no-audit + npm run build - name: Run Tests - run: ./vendor/bin/pest + run: ./vendor/bin/pest --ci --coverage diff --git a/.gitignore b/.gitignore index 33806df..1f4f617 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,15 @@ yarn-error.log /.vscode /.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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7bc786e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,17 @@ +### CONTRIBUTING +Contributions are welcome! If you’d like to improve the project, follow these steps: + +1. Open an Issue + - Before submitting a pull request, create an issue to discuss your idea. + - Clearly describe the feature or bug fix you want to work on. +2. Fork the Repository & Create a Branch +3. Make Your Changes & Add Tests + - Ensure your code follows best practices. + - Add Pest tests to cover your changes. +4. Run Tests + - `php artisan test` +5. Submit a Pull Request (PR) + - Push your branch and create a PR. + - Provide a clear description of your changes. + +Thank you for contributing! diff --git a/Dockerfile b/Dockerfile index 6bae527..2d761ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,68 +1,57 @@ -FROM php:8.3-fpm-alpine3.20 +######################## +# Base Image +######################## +FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base -# Install system dependencies -RUN apk add --no-cache \ - nginx \ - supervisor \ - libpq \ - nodejs \ - npm \ - git \ - curl \ - zip \ - unzip \ - imagemagick-dev \ - chromium +LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel +LABEL org.opencontainers.image.description="TRMNL BYOS Laravel" +LABEL org.opencontainers.image.licenses=MIT -# Configure Chromium Path -ENV PUPPETEER_EXECUTABLE_PATH /usr/bin/chromium -ENV PUPPETEER_DOCKER 1 +ARG APP_VERSION +ENV APP_VERSION=${APP_VERSION} -#RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS imagemagick-dev \ -#&& pecl install imagick \ -#&& docker-php-ext-enable imagick \ -#&& apk del .build-deps \ +ENV AUTORUN_ENABLED="true" -#RUN docker-php-ext-install imagick \ -# && docker-php-ext-enable imagick +# Mark trmnl-liquid-cli as installed +ENV TRMNL_LIQUID_ENABLED=1 -RUN mkdir -p /usr/src/php/ext/imagick -RUN chmod 777 /usr/src/php/ext/imagick -RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.7.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 +# Switch to the root user so we can do root things +USER root -# Install PHP extensions -RUN docker-php-ext-install opcache imagick +COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/ -# Install composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# Set working directory +# Set the working directory WORKDIR /var/www/html -# Copy application files -COPY --chown=www-data:www-data . . -COPY --chown=www-data:www-data ./.env.example ./.env +# Copy the application files +COPY --chown=www-data:www-data . /var/www/html +COPY --chown=www-data:www-data .env.example .env -# Install application dependencies -RUN composer install --no-interaction --prefer-dist --optimize-autoloader -RUN npm install && npm run build +# Install the composer dependencies +RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader -# Copy configuration files -COPY docker/nginx.conf /etc/nginx/http.d/default.conf -COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf -COPY docker/php.ini /usr/local/etc/php/conf.d/custom.ini +######################## +# Assets Image +######################## +FROM node:22-alpine AS assets -# Create required directories -RUN mkdir -p /var/log/supervisor \ - && mkdir -p storage/logs \ - && mkdir -p storage/framework/{cache,sessions,views} \ - && chmod -R 775 storage \ - && chmod -R 775 bootstrap/cache \ - && touch database/database.sqlite \ - && chmod -R 777 database +# Copy the application +COPY --from=base /var/www/html /app -# Expose port 80 -EXPOSE 80 +# Set the working directory +WORKDIR /app -# Start supervisor -CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"] +# Install the node dependencies and build the assets +RUN npm ci --no-audit \ + && npm run build + +######################## +# Production Image +######################## +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 5600782..acb0b5c 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,137 @@ ## TRMNL BYOS (PHP/Laravel) -Laravel Trmnl Server is a self-hostable implementation of a TRMNL server, built with Laravel. -It enables you to manage TRMNL devices, generate screens dynamically, and can act as a proxy for the TRMNL API (native plugin system). -Inspired by [usetrmnl/byos_sinatra](https://github.com/usetrmnl/byos_sinatra). +[![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml) -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). +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. ![Screenshot](README_byos-screenshot.png) +![Screenshot](README_byos-screenshot-dark.png) * πŸ‘‰ [more Screenshots](screenshots/SCREENSHOTS.md) ### Key Features -* πŸ“‘ 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 Markup, API, or update via Code. -* πŸ”„ TRMNL API Proxy – Can act as a proxy for the TRMNL Display API (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. +* πŸ“‘ 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) +* πŸ”„ 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. -### 🎯 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. +![Devices](README_byos-devices.jpeg) ### Support ❀️ This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau). -Support the development of this package by purchasing a TRMNL device through our referral link: https://usetrmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase. +Support the development of this package by purchasing a TRMNL device through the referral link: https://usetrmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase. -### Requirements +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, ...). +For production use, generate a new APP_KEY (`php artisan key:generate --show`) and set the environment variable `APP_KEY=`. For personal use, you can disable registration (see section Environment Variables). + +#### Docker Compose +Docker Compose file located at: [docker/prod/docker-compose.yml](docker/prod/docker-compose.yml). + +##### Backup Database +```sh +docker ps #find container id of byos_laravel container +docker cp {{CONTAINER_ID}}:/var/www/html/database/storage/database.sqlite database_backup.sqlite +``` + +##### Updating via Docker Compose +```sh +docker compose pull +docker compose down +docker compose up -d +``` + +#### VPS +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 +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. + +#### Requirements * PHP >= 8.2 * ext-imagick * puppeteer [see Browsershot docs](https://spatie.be/docs/browsershot/v4/requirements) -### Installation +### Local Development -#### Clone the repository +see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) + + +### Demo Plugins + +Run the ExampleRecipesSeeder to seed the database with example plugins: ```bash -git clone git@github.com:bnussbau/laravel-trmnl-server.git +php artisan db:seed --class=ExampleRecipesSeeder ``` -#### Copy environment file - -```bash -cp .env.example .env -php artisan key:generate -``` - -#### Install dependencies - -```bash -composer install -npm i -``` - -#### Run migrations - -```bash -php artisan migrate --seed -``` - -#### Run the server - -To make your server accessible in the network, you can run the following command: - -```bash -php artisan serve --host=0.0.0.0 --port 4567 -``` - -### Docker -Use the provided Dockerfile, or docker-compose file to run the server in a container. -You can persist the database file by mounting a volume to `/var/www/html/database/database.sqlite`. - -```Dockerfile -# docker-compose.yaml -volumes: - - ./database/database.sqlite:/var/www/html/database/database.sqlite -``` +* Zen Quotes +* This Day in History +* Weather +* Train Departure Monitor +* Home Assistant +* Sunrise/Sunset ### Usage #### Environment Variables -| environment | description | default | -|-------------------------------|------------------------------------------------------------------|-------------------| -| `TRMNL_PROXY_BASE_URL` | Base URL of the native TRMNL service | https://trmnl.app | -| `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 | -| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 | -| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 | +| Environment Variable | Description | Default | +|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|-------------------| +| `TRMNL_PROXY_BASE_URL` | Base URL of the native TRMNL service | https://trmnl.app | +| `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 | +| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 | +| `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` | +| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 | +| `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 | +| `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 | +| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC | #### Login If your environment is local, you can access the server at `http://localhost:4567` and login with user / password `admin@example.com` / `admin@example.com`, otherwise register. With environment variable `REGISTRATION_ENABLED` you can control, if registration is allowed. -#### βž• Add Your TRMNL Device +### βž• Add Your TRMNL Device ##### Auto-Join (Local Network) @@ -109,11 +143,12 @@ If your environment is local, you can access the server at `http://localhost:456 ##### Manually 1. Open the Devices page: -πŸ‘‰ http://localhost:4567/devices + πŸ‘‰ http://localhost:4567/devices 2. Click β€œAdd New Device”. 3. Retrieve your TRMNL MAC Address and API Key: - - You can grab the TRMNL Mac Address and API Key from the TRMNL Dashboard - - Alternatively, debug incoming requests to /api/setup to determine them +- You can grab the TRMNL Mac Address and API Key from the TRMNL Dashboard +- Alternatively, debug incoming requests to /api/setup to determine them + ### βš™οΈ Configure Server for Device @@ -129,11 +164,37 @@ If your device firmware is older than 1.4.6, you need to flash a new firmware ve See this YouTube guide: [https://www.youtube.com/watch?v=3xehPW-PCOM](https://www.youtube.com/watch?v=3xehPW-PCOM) +### ☁️ Activate fresh TRMNL Device with Cloud Proxy + +1) Setup the TRMNL as in the official docs with the cloud service (connect one of the plugins to later verify it works) +2) Setup Laravel BYOS, create a user and login +3) In Laravel BYOS in the header bar, activate the toggle "Permit Auto-Join" +4) Press and hold the button on the back of your TRMNL for 5 seconds to reactivate the captive portal (or reflash). +5) Go through the setup process again, in the screen where you provide the Wi-Fi credentials there is also option to set the Server URL. Use the local address of your Laravel BYOS +6) The device should automatically appear in the device list; you can deactivate the "Permit Auto-Join" toggle again. +7) In the devices list, activate the toggle "☁️ Proxy" for your device. (Make sure that the queue worker is active. In the docker image it should be running automatically.) +8) As long as no Laravel BYOS plugin is scheduled, the device will show your cloud plugins. + +###### Troubleshooting + +Make sure that your device has a Developer license, you should be able to verify by calling the `https://trmnl.app/api/display` endpoint. + +* [https://docs.usetrmnl.com/go/private-api/introduction](https://docs.usetrmnl.com/go/private-api/introduction) +* [https://docs.usetrmnl.com/go/private-api/fetch-screen-content](https://docs.usetrmnl.com/go/private-api/fetch-screen-content) + ### πŸ–₯️ Generate Screens +#### Markup via Web Interface + +1. Navigate to Plugins > Markup in the Web Interface. +2. Enter your markup manually or select from the available templates. +3. Save and apply the changes. + +* Available Blade Components are listed here: [laravel-trmnl-blade | Blade Components](https://github.com/bnussbau/laravel-trmnl-blade/tree/main/resources/views/components) + #### 🎨 Blade View * Edit `resources/views/trmnl.blade.php` - * Available Blade Components are listed here: [laravel-trmnl | Blade Components](https://github.com/bnussbau/laravel-trmnl/tree/main/resources/views/components) + * Available Blade Components are listed here: [laravel-trmnl-blade | Blade Components](https://github.com/bnussbau/laravel-trmnl-blade/tree/main/resources/views/components) * To generate the screen, run ```bash @@ -157,109 +218,15 @@ You can dynamically update screens by sending a POST request. } ``` -Token can be retrieved under Plugins > API in the Web Interface. +### 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. -#### Markup via Web Interface - -1. Navigate to Plugins > Markup in the Web Interface. -2. Enter your markup manually or select from the available templates. -3. Save and apply the changes. - -* Available Blade Components are listed here: [laravel-trmnl | Blade Components](https://github.com/bnussbau/laravel-trmnl/tree/main/resources/views/components) - -#### πŸ› οΈ Generate Screens Programmatically - -You can fetch external data, process it, and generate screens dynamically. -* Fetch data from an external source. -* Either render it in a Blade view or directly insert markup. -* Use Laravel’s scheduler to automate updates. - -#### πŸ“Œ Example: Fetch Train Monitor Data - -This example retrieves data from [trmnl-train-monitor](https://github.com/bnussbau/trmnl-train-monitor) and updates the screen periodically. - -##### Step 1: Create a new Artisan Command - -```bash -php artisan make:command PluginTrainMonitorFetch -``` - -##### Step 2: Edit PluginTrainMonitorFetch.php - -```php -class PluginTrainMonitorFetch extends Command -{ - protected $signature = 'plugin:train:fetch'; - - protected $description = 'Fetches train monitor data and updates the screen'; - - public function handle(): void - { - $markup = Http::get('https://oebb.trmnl.yourserver.at/markup')->json('markup'); - GenerateScreenJob::dispatchSync(1, $markup); - } -} -``` - -##### Step 3: Schedule the Command in console.php - -```php -Schedule::command('plugin:train:fetch') - ->everyFiveMinutes() - ->timezone('Europe/Vienna') - ->between('5:00', '18:00'); -``` - -This will automatically update the screen every 5 minutes between 5:00 AM and 6:00 PM local time. - -### πŸ—οΈ Roadmap - -Here are some features and improvements that are open for contribution: - -##### πŸ”Œ Plugin System - -- Enable configurable plugins via the Web Interface. -- Ensure compatibility with the trmnl-laravel package. -- Implement auto-discovery for plugins. - -##### ⏳ Scheduling - -- Move task scheduling from console.php to a Web Interface. -- Allow users to configure custom schedule intervals. - -##### πŸ–₯️ β€œNative” Plugins -- Add built-in plugins such as (as an example): - - ☁️ Weather - - πŸ’¬ Quotes - - 🏑 Home Assistant integration -- Provide Web UI controls to enable/disable plugins. - -##### πŸ“¦ Visual Studio Code Devcontainer -* Add a .devcontainer to this repo for easier development with Docker. - -##### Improve Code Coverage - -- Expand Pest tests to cover more functionality. -- Increase code coverage (currently at 86.9%). ### 🀝 Contribution -Contributions are welcome! If you’d like to improve the project, follow these steps: - -1. Open an Issue - - Before submitting a pull request, create an issue to discuss your idea. - - Clearly describe the feature or bug fix you want to work on. -2. Fork the Repository & Create a Branch -3. Make Your Changes & Add Tests - - Ensure your code follows best practices. - - Add Pest tests to cover your changes. -4. Run Tests - - `php artisan test` -5. Submit a Pull Request (PR) - - Push your branch and create a PR. - - Provide a clear description of your changes. - -πŸš€ Thank you for contributing! Every contribution helps improve the project. +Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details. ### License -MIT +[MIT](LICENSE.md) diff --git a/README_byos-devices.jpeg b/README_byos-devices.jpeg new file mode 100644 index 0000000..1e49a2c Binary files /dev/null and b/README_byos-devices.jpeg differ diff --git a/README_byos-screenshot-dark.png b/README_byos-screenshot-dark.png new file mode 100644 index 0000000..2b8174e Binary files /dev/null and b/README_byos-screenshot-dark.png differ diff --git a/README_byos-screenshot.png b/README_byos-screenshot.png index 7cd524a..b84d090 100644 Binary files a/README_byos-screenshot.png and b/README_byos-screenshot.png differ diff --git a/app/Console/Commands/ExampleRecipesSeederCommand.php b/app/Console/Commands/ExampleRecipesSeederCommand.php new file mode 100644 index 0000000..9146276 --- /dev/null +++ b/app/Console/Commands/ExampleRecipesSeederCommand.php @@ -0,0 +1,20 @@ +argument('user_id'); + $seeder->run($user_id); + } +} diff --git a/app/Console/Commands/FetchDeviceModelsCommand.php b/app/Console/Commands/FetchDeviceModelsCommand.php new file mode 100644 index 0000000..78dd02a --- /dev/null +++ b/app/Console/Commands/FetchDeviceModelsCommand.php @@ -0,0 +1,46 @@ +info('Dispatching FetchDeviceModelsJob...'); + + try { + FetchDeviceModelsJob::dispatchSync(); + + $this->info('FetchDeviceModelsJob has been dispatched successfully.'); + + return self::SUCCESS; + } catch (Exception $e) { + $this->error('Failed to dispatch FetchDeviceModelsJob: '.$e->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/FirmwareCheckCommand.php b/app/Console/Commands/FirmwareCheckCommand.php new file mode 100644 index 0000000..91922ba --- /dev/null +++ b/app/Console/Commands/FirmwareCheckCommand.php @@ -0,0 +1,38 @@ + FirmwarePollJob::dispatchSync(download: $this->option('download')), + message: 'Checking for latest firmware...' + ); + + $latestFirmware = Firmware::getLatest(); + if ($latestFirmware instanceof Firmware) { + table( + rows: [ + ['Latest Version', $latestFirmware->version_tag], + ['Download URL', $latestFirmware->url], + ['Storage Location', $latestFirmware->storage_location], + ] + ); + } else { + $this->error('No firmware found.'); + } + } +} diff --git a/app/Console/Commands/FirmwareUpdateCommand.php b/app/Console/Commands/FirmwareUpdateCommand.php new file mode 100644 index 0000000..bd43786 --- /dev/null +++ b/app/Console/Commands/FirmwareUpdateCommand.php @@ -0,0 +1,70 @@ + 'Check. Devices will download binary from the original source.', + 'download' => 'Check & Download. Devices will download binary from BYOS.', + 'no' => 'Do not check.', + ], + ); + + if ($checkFirmware !== 'no') { + $this->call('trmnl:firmware:check', [ + '--download' => $checkFirmware === 'download', + ]); + } + + $firmwareVersion = select( + label: 'Update to which version?', + options: Firmware::pluck('version_tag', 'id') + ); + + $devices = multiselect( + label: 'Which devices should be updated?', + options: [ + 'all' => 'ALL Devices', + ...Device::all()->mapWithKeys(fn ($device): array => + // without _ returns index + ["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"])->toArray(), + ], + scroll: 10 + ); + + if ($devices === []) { + $this->error('No devices selected. Aborting.'); + + return; + } + + if (in_array('all', $devices)) { + $devices = Device::pluck('id')->toArray(); + } else { + $devices = array_map(fn ($selected): int => (int) str_replace('_', '', $selected), $devices); + } + + foreach ($devices as $deviceId) { + Device::find($deviceId)->update(['update_firmware_id' => $firmwareVersion]); + + $this->info("Device with id [$deviceId] will update firmware on next request."); + } + } +} diff --git a/app/Console/Commands/GenerateDefaultImagesCommand.php b/app/Console/Commands/GenerateDefaultImagesCommand.php new file mode 100644 index 0000000..e2887df --- /dev/null +++ b/app/Console/Commands/GenerateDefaultImagesCommand.php @@ -0,0 +1,201 @@ +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 new file mode 100644 index 0000000..7201274 --- /dev/null +++ b/app/Console/Commands/MashupCreateCommand.php @@ -0,0 +1,175 @@ +selectDevice(); + if (! $device instanceof Device) { + return 1; + } + + // Select playlist + $playlist = $this->selectPlaylist($device); + if (! $playlist instanceof Playlist) { + return 1; + } + + // Select mashup layout + $layout = $this->selectLayout(); + if (! $layout) { + return 1; + } + + // Get mashup name + $name = $this->getMashupName(); + if (! $name) { + return 1; + } + + // Select plugins + $plugins = $this->selectPlugins($layout); + if ($plugins->isEmpty()) { + return 1; + } + + $maxOrder = $playlist->items()->max('order') ?? 0; + + // Create playlist item with mashup + PlaylistItem::createMashup( + playlist: $playlist, + layout: $layout, + pluginIds: $plugins->pluck('id')->toArray(), + name: $name, + order: $maxOrder + 1 + ); + + $this->info('Mashup created successfully!'); + + return 0; + } + + protected function selectDevice(): ?Device + { + $devices = Device::all(); + if ($devices->isEmpty()) { + $this->error('No devices found. Please create a device first.'); + + return null; + } + + $deviceId = $this->choice( + 'Select a device', + $devices->mapWithKeys(fn ($device): array => [$device->id => $device->name])->toArray() + ); + + return $devices->firstWhere('id', $deviceId); + } + + protected function selectPlaylist(Device $device): ?Playlist + { + /** @var Collection|Playlist[] $playlists */ + $playlists = $device->playlists; + if ($playlists->isEmpty()) { + $this->error('No playlists found for this device. Please create a playlist first.'); + + return null; + } + + $playlistId = $this->choice( + 'Select a playlist', + $playlists->mapWithKeys(fn (Playlist $playlist): array => [$playlist->id => $playlist->name])->toArray() + ); + + return $playlists->firstWhere('id', $playlistId); + } + + protected function selectLayout(): ?string + { + return $this->choice( + 'Select a layout', + 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; + } + + protected function selectPlugins(string $layout): Collection + { + $requiredCount = PlaylistItem::getRequiredPluginCountForLayout($layout); + + $plugins = Plugin::all(); + if ($plugins->isEmpty()) { + $this->error('No plugins found. Please create some plugins first.'); + + return collect(); + } + + $selectedPlugins = collect(); + $availablePlugins = $plugins->mapWithKeys(fn ($plugin): array => [$plugin->id => $plugin->name])->toArray(); + + for ($i = 0; $i < $requiredCount; ++$i) { + $position = match ($i) { + 0 => 'first', + 1 => 'second', + 2 => 'third', + 3 => 'fourth', + default => ($i + 1).'th' + }; + + $pluginId = $this->choice( + "Select the $position plugin", + $availablePlugins + ); + + $selectedPlugins->push($plugins->firstWhere('id', $pluginId)); + unset($availablePlugins[$pluginId]); + } + + return $selectedPlugins; + } +} diff --git a/app/Console/Commands/OidcTestCommand.php b/app/Console/Commands/OidcTestCommand.php new file mode 100644 index 0000000..81dff0b --- /dev/null +++ b/app/Console/Commands/OidcTestCommand.php @@ -0,0 +1,104 @@ +info('Testing OIDC Configuration...'); + $this->newLine(); + + // Check if OIDC is enabled + $enabled = config('services.oidc.enabled'); + $this->line('OIDC Enabled: '.($enabled ? 'βœ… Yes' : '❌ No')); + + // Check configuration values + $endpoint = config('services.oidc.endpoint'); + $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->newLine(); + + // Test driver registration + try { + // Only test driver if we have basic configuration + if ($endpoint && $clientId && $clientSecret) { + $driver = Socialite::driver('oidc'); + $this->line('OIDC Driver: βœ… Successfully registered and accessible'); + + if ($enabled) { + $this->info('βœ… OIDC is fully configured and ready to use!'); + $this->line('You can test the login flow at: /auth/oidc/redirect'); + } else { + $this->warn('⚠️ OIDC driver is working but OIDC_ENABLED is false.'); + } + } else { + $this->line('OIDC Driver: βœ… Registered (configuration test skipped due to missing values)'); + $this->warn('⚠️ OIDC driver is registered but missing required configuration.'); + $this->line('Please set the following environment variables:'); + if (! $enabled) { + $this->line(' - OIDC_ENABLED=true'); + } + if (! $endpoint) { + $this->line(' - OIDC_ENDPOINT=https://your-oidc-provider.com (base URL)'); + $this->line(' OR'); + $this->line(' - OIDC_ENDPOINT=https://your-oidc-provider.com/.well-known/openid-configuration (full URL)'); + } + if (! $clientId) { + $this->line(' - OIDC_CLIENT_ID=your-client-id'); + } + if (! $clientSecret) { + $this->line(' - OIDC_CLIENT_SECRET=your-client-secret'); + } + } + } catch (InvalidArgumentException $e) { + if (str_contains($e->getMessage(), 'Driver [oidc] not supported')) { + $this->error('❌ OIDC Driver registration failed: Driver not supported'); + } else { + $this->error('❌ OIDC Driver error: '.$e->getMessage()); + } + } catch (Exception $e) { + $this->warn('⚠️ OIDC Driver registered but configuration error: '.$e->getMessage()); + } + + $this->newLine(); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/ScreenGeneratorCommand.php b/app/Console/Commands/ScreenGeneratorCommand.php index baafacb..c0a2cc3 100644 --- a/app/Console/Commands/ScreenGeneratorCommand.php +++ b/app/Console/Commands/ScreenGeneratorCommand.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; use App\Jobs\GenerateScreenJob; use Illuminate\Console\Command; +use Throwable; class ScreenGeneratorCommand extends Command { @@ -24,20 +25,19 @@ class ScreenGeneratorCommand extends Command /** * Execute the console command. */ - public function handle() + public function handle(): int { $deviceId = $this->argument('deviceId'); $view = $this->argument('view'); try { $markup = view($view)->render(); - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->error('Failed to render view: '.$e->getMessage()); return 1; } - - GenerateScreenJob::dispatchSync($deviceId, $markup); + GenerateScreenJob::dispatchSync($deviceId, null, $markup); $this->info('Screen generation job finished.'); diff --git a/app/Enums/ImageFormat.php b/app/Enums/ImageFormat.php new file mode 100644 index 0000000..67e9b79 --- /dev/null +++ b/app/Enums/ImageFormat.php @@ -0,0 +1,23 @@ + 'Auto', + self::PNG_8BIT_GRAYSCALE => 'PNG 8-bit Grayscale Gray 2c', + self::BMP3_1BIT_SRGB => 'BMP3 1-bit sRGB 2c', + self::PNG_8BIT_256C => 'PNG 8-bit Grayscale Gray 256c', + self::PNG_2BIT_4C => 'PNG 2-bit Grayscale 4c', + }; + } +} diff --git a/app/Http/Controllers/Auth/OidcController.php b/app/Http/Controllers/Auth/OidcController.php new file mode 100644 index 0000000..f7847d9 --- /dev/null +++ b/app/Http/Controllers/Auth/OidcController.php @@ -0,0 +1,123 @@ +route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']); + } + + // Check if all required OIDC configuration is present + $requiredConfig = ['endpoint', 'client_id', 'client_secret']; + foreach ($requiredConfig as $key) { + if (! config("services.oidc.{$key}")) { + Log::error("OIDC configuration missing: {$key}"); + + return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']); + } + } + + try { + return Socialite::driver('oidc')->redirect(); + } catch (Exception $e) { + Log::error('OIDC redirect error: '.$e->getMessage()); + + return redirect()->route('login')->withErrors(['oidc' => 'Failed to initiate OIDC authentication.']); + } + } + + /** + * Obtain the user information from the OIDC provider. + */ + public function callback(Request $request) + { + if (! config('services.oidc.enabled')) { + return redirect()->route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']); + } + + // Check if all required OIDC configuration is present + $requiredConfig = ['endpoint', 'client_id', 'client_secret']; + foreach ($requiredConfig as $key) { + if (! config("services.oidc.{$key}")) { + Log::error("OIDC configuration missing: {$key}"); + + return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']); + } + } + + try { + $oidcUser = Socialite::driver('oidc')->user(); + + // Find or create the user + $user = $this->findOrCreateUser($oidcUser); + + // Log the user in + Auth::login($user, true); + + return redirect()->intended(route('dashboard', absolute: false)); + + } catch (Exception $e) { + Log::error('OIDC callback error: '.$e->getMessage()); + + return redirect()->route('login')->withErrors(['oidc' => 'Failed to authenticate with OIDC provider.']); + } + } + + /** + * Find or create a user based on OIDC information. + */ + protected function findOrCreateUser($oidcUser) + { + // First, try to find user by OIDC subject ID + $user = User::where('oidc_sub', $oidcUser->getId())->first(); + + if ($user) { + // Update user information from OIDC + $user->update([ + 'name' => $oidcUser->getName() ?: $user->name, + 'email' => $oidcUser->getEmail() ?: $user->email, + ]); + + return $user; + } + + // If not found by OIDC sub, try to find by email + if ($oidcUser->getEmail()) { + $user = User::where('email', $oidcUser->getEmail())->first(); + + if ($user) { + // Link the existing user with OIDC + $user->update([ + 'oidc_sub' => $oidcUser->getId(), + 'name' => $oidcUser->getName() ?: $user->name, + ]); + + return $user; + } + } + + // Create new user + return User::create([ + 'oidc_sub' => $oidcUser->getId(), + 'name' => $oidcUser->getName() ?: 'OIDC User', + 'email' => $oidcUser->getEmail() ?: $oidcUser->getId().'@oidc.local', + 'password' => bcrypt(Str::random(32)), // Random password since we're using OIDC + 'email_verified_at' => now(), // OIDC users are considered verified + ]); + } +} diff --git a/app/Jobs/CleanupDeviceLogsJob.php b/app/Jobs/CleanupDeviceLogsJob.php new file mode 100644 index 0000000..d2f1dd9 --- /dev/null +++ b/app/Jobs/CleanupDeviceLogsJob.php @@ -0,0 +1,30 @@ +logs()->latest('device_timestamp')->take(50)->pluck('id'); + + // Delete all other logs for this device + $device->logs() + ->whereNotIn('id', $keepIds) + ->delete(); + }); + } +} diff --git a/app/Jobs/FetchDeviceModelsJob.php b/app/Jobs/FetchDeviceModelsJob.php new file mode 100644 index 0000000..475c5c7 --- /dev/null +++ b/app/Jobs/FetchDeviceModelsJob.php @@ -0,0 +1,247 @@ +processPalettes(); + + $response = Http::timeout(30)->get(self::API_URL); + + if (! $response->successful()) { + Log::error('Failed to fetch device models from API', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return; + } + + $data = $response->json('data', []); + + if (! is_array($data)) { + Log::error('Invalid response format from device models API', [ + 'response' => $response->json(), + ]); + + return; + } + + $this->processDeviceModels($data); + + Log::info('Successfully fetched and updated device models', [ + 'count' => count($data), + ]); + + } catch (Exception $e) { + Log::error('Exception occurred while fetching device models', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + /** + * 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. + */ + private function processDeviceModels(array $deviceModels): void + { + foreach ($deviceModels as $modelData) { + try { + $this->updateOrCreateDeviceModel($modelData); + } catch (Exception $e) { + Log::error('Failed to process device model', [ + 'model_data' => $modelData, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * Update or create a device model record. + */ + private function updateOrCreateDeviceModel(array $modelData): void + { + $name = $modelData['name'] ?? null; + + if (! $name) { + Log::warning('Device model data missing name field', [ + 'model_data' => $modelData, + ]); + + return; + } + + $attributes = [ + 'label' => $modelData['label'] ?? '', + 'description' => $modelData['description'] ?? '', + 'width' => $modelData['width'] ?? 0, + 'height' => $modelData['height'] ?? 0, + 'colors' => $modelData['colors'] ?? 0, + 'bit_depth' => $modelData['bit_depth'] ?? 0, + 'scale_factor' => $modelData['scale_factor'] ?? 1, + 'rotation' => $modelData['rotation'] ?? 0, + 'mime_type' => $modelData['mime_type'] ?? '', + '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 31fb5bf..ac23130 100644 --- a/app/Jobs/FetchProxyCloudResponses.php +++ b/app/Jobs/FetchProxyCloudResponses.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\Device; +use Exception; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -11,6 +12,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class FetchProxyCloudResponses implements ShouldQueue { @@ -21,53 +23,10 @@ class FetchProxyCloudResponses implements ShouldQueue */ public function handle(): void { - Device::where('proxy_cloud', true)->each(function ($device) { - try { - $response = Http::withHeaders([ - 'id' => $device->mac_address, - 'access-token' => $device->api_key, - 'width' => 800, - 'height' => 480, - 'rssi' => $device->last_rssi_level, - 'battery_voltage' => $device->last_battery_voltage, - 'refresh-rate' => $device->default_refresh_interval, - 'fw-version' => $device->last_firmware_version, - 'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0', - 'user-agent' => 'ESP32HTTPClient', - ])->get(config('services.trmnl.proxy_base_url').'/api/display'); - - $device->update([ - 'proxy_cloud_response' => $response->json(), - ]); - - $imageUrl = $response->json('image_url'); - $filename = $response->json('filename'); - - \Log::info('Response data: '.$imageUrl); - if (isset($imageUrl)) { - try { - $imageContents = Http::get($imageUrl)->body(); - if (! Storage::disk('public')->exists("images/generated/{$filename}.bmp")) { - Storage::disk('public')->put( - "images/generated/{$filename}.bmp", - $imageContents - ); - } - - $device->update([ - 'current_screen_image' => $filename, - ]); - } catch (\Exception $e) { - Log::error("Failed to download and save image for device: {$device->mac_address}", [ - 'error' => $e->getMessage(), - ]); - } - } - - Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}"); - - if ($device->last_log_request) { - Http::withHeaders([ + Device::where('proxy_cloud', true)->each(function ($device): void { + if (! $device->getNextPlaylistItem()) { + try { + $response = Http::withHeaders([ 'id' => $device->mac_address, 'access-token' => $device->api_key, 'width' => 800, @@ -78,17 +37,80 @@ class FetchProxyCloudResponses implements ShouldQueue 'fw-version' => $device->last_firmware_version, 'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0', 'user-agent' => 'ESP32HTTPClient', - ])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request); + ])->get(config('services.trmnl.proxy_base_url').'/api/display'); $device->update([ - 'last_log_request' => null, + 'proxy_cloud_response' => $response->json(), + ]); + + $imageUrl = $response->json('image_url'); + $filename = $response->json('filename'); + + parse_str(parse_url($imageUrl)['query'] ?? '', $queryParams); + $imageType = urldecode($queryParams['response-content-type'] ?? 'image/bmp'); + $imageExtension = $imageType === 'image/png' ? 'png' : 'bmp'; + + if (Str::contains($imageUrl, '.png')) { + $imageExtension = 'png'; + } + + \Log::info("Response data: $imageUrl. Image Extension: $imageExtension"); + if (isset($imageUrl)) { + try { + $imageContents = Http::get($imageUrl)->body(); + if (! Storage::disk('public')->exists("images/generated/{$filename}.{$imageExtension}")) { + Storage::disk('public')->put( + "images/generated/{$filename}.{$imageExtension}", + $imageContents + ); + } + + $device->update([ + 'current_screen_image' => $filename, + ]); + } catch (Exception $e) { + Log::error("Failed to download and save image for device: {$device->mac_address}", [ + 'error' => $e->getMessage(), + ]); + } + } + + Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}"); + + if ($device->last_log_request) { + try { + Http::withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'width' => 800, + 'height' => 480, + 'rssi' => $device->last_rssi_level, + 'battery_voltage' => $device->last_battery_voltage, + 'refresh-rate' => $device->default_refresh_interval, + 'fw-version' => $device->last_firmware_version, + 'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0', + 'user-agent' => 'ESP32HTTPClient', + ])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request); + + // Only clear the pending log request if the POST succeeded + $device->update([ + 'last_log_request' => null, + ]); + } catch (Exception $e) { + // Do not fail the entire proxy fetch if the log upload fails + Log::error("Failed to upload device log for device: {$device->mac_address}", [ + 'error' => $e->getMessage(), + ]); + } + } + + } catch (Exception $e) { + Log::error("Failed to fetch proxy cloud response for device: {$device->mac_address}", [ + 'error' => $e->getMessage(), ]); } - - } catch (\Exception $e) { - Log::error("Failed to fetch proxy cloud response for device: {$device->mac_address}", [ - 'error' => $e->getMessage(), - ]); + } else { + Log::info("Skipping device: {$device->mac_address} as it has a pending playlist item."); } }); } diff --git a/app/Jobs/FirmwareDownloadJob.php b/app/Jobs/FirmwareDownloadJob.php new file mode 100644 index 0000000..dfc851d --- /dev/null +++ b/app/Jobs/FirmwareDownloadJob.php @@ -0,0 +1,52 @@ +exists('firmwares')) { + Storage::disk('public')->makeDirectory('firmwares'); + } + + try { + $filename = "FW{$this->firmware->version_tag}.bin"; + $response = Http::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 new file mode 100644 index 0000000..c1a2267 --- /dev/null +++ b/app/Jobs/FirmwarePollJob.php @@ -0,0 +1,53 @@ +json(); + + if (! is_array($response) || ! isset($response['version']) || ! isset($response['url'])) { + Log::error('Invalid firmware response format received'); + + return; + } + + $latestFirmware = Firmware::updateOrCreate( + ['version_tag' => $response['version']], + [ + 'url' => $response['url'], + 'latest' => true, + ] + ); + + Firmware::where('id', '!=', $latestFirmware->id)->update(['latest' => false]); + + if ($this->download && $latestFirmware->url && $latestFirmware->storage_location === null) { + FirmwareDownloadJob::dispatchSync($latestFirmware); + } + + } catch (ConnectionException $e) { + Log::error('Firmware download failed: '.$e->getMessage()); + } catch (Exception $e) { + Log::error('Unexpected error in firmware polling: '.$e->getMessage()); + } + } +} diff --git a/app/Jobs/GenerateScreenJob.php b/app/Jobs/GenerateScreenJob.php index e1d0d25..b9661cc 100644 --- a/app/Jobs/GenerateScreenJob.php +++ b/app/Jobs/GenerateScreenJob.php @@ -3,14 +3,13 @@ namespace App\Jobs; use App\Models\Device; +use App\Models\Plugin; +use App\Services\ImageGenerationService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Storage; -use Ramsey\Uuid\Uuid; -use Spatie\Browsershot\Browsershot; class GenerateScreenJob implements ShouldQueue { @@ -21,6 +20,7 @@ class GenerateScreenJob implements ShouldQueue */ public function __construct( private readonly int $deviceId, + private readonly ?int $pluginId, private readonly string $markup ) {} @@ -29,61 +29,15 @@ class GenerateScreenJob implements ShouldQueue */ public function handle(): void { - $uuid = Uuid::uuid4()->toString(); - $pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png'); - $bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp'); + $newImageUuid = ImageGenerationService::generateImage($this->markup, $this->deviceId); - // Generate PNG - try { - Browsershot::html($this->markup) - ->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []) - ->windowSize(800, 480) - ->save($pngPath); - } catch (\Exception $e) { - throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); + Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]); + + if ($this->pluginId) { + // cache current image + Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]); } - try { - $this->convertToBmpImageMagick($pngPath, $bmpPath); - } catch (\ImagickException $e) { - throw new \RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e); - } - Device::find($this->deviceId)->update(['current_screen_image' => $uuid]); - \Log::info("Device $this->deviceId: updated with new image: $uuid"); - - $this->cleanupFolder(); - } - - /** - * @throws \ImagickException - */ - private 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(); - } - - private function cleanupFolder(): void - { - $activeImageUuids = Device::pluck('current_screen_image')->filter()->toArray(); - - $files = Storage::disk('public')->files('/images/generated/'); - foreach ($files as $file) { - if (basename($file) === '.gitignore') { - continue; - } - // Get filename without path and extension - $fileUuid = pathinfo($file, PATHINFO_FILENAME); - // If the UUID is not in use by any device, move it to archive - if (! in_array($fileUuid, $activeImageUuids)) { - Storage::disk('public')->delete($file); - } - } + ImageGenerationService::cleanupFolder(); } } diff --git a/app/Jobs/NotifyDeviceBatteryLowJob.php b/app/Jobs/NotifyDeviceBatteryLowJob.php new file mode 100644 index 0000000..9b1001b --- /dev/null +++ b/app/Jobs/NotifyDeviceBatteryLowJob.php @@ -0,0 +1,54 @@ +battery_percent; + + // If battery is above threshold, reset the notification flag + if ($batteryPercent > $batteryThreshold && $device->battery_notification_sent) { + $device->battery_notification_sent = false; + $device->save(); + + continue; + } + // Skip if battery is not low or notification was already sent + if ($batteryPercent > $batteryThreshold) { + continue; + } + if ($device->battery_notification_sent) { + continue; + } + + /** @var User|null $user */ + $user = $device->user; + + if (! $user) { + continue; // Skip if no user is associated with the device + } + + // Send notification and mark as sent + $user->notify(new BatteryLow($device)); + $device->battery_notification_sent = true; + $device->save(); + } + } +} diff --git a/app/Liquid/FileSystems/InlineTemplatesFileSystem.php b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php new file mode 100644 index 0000000..dbde888 --- /dev/null +++ b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php @@ -0,0 +1,62 @@ + + */ + protected array $templates = []; + + /** + * Register a template with the given name and content + */ + public function register(string $name, string $content): void + { + $this->templates[$name] = $content; + } + + /** + * Check if a template exists + */ + public function hasTemplate(string $templateName): bool + { + return isset($this->templates[$templateName]); + } + + /** + * Get all registered template names + * + * @return array + */ + public function getTemplateNames(): array + { + return array_keys($this->templates); + } + + /** + * Clear all registered templates + */ + public function clear(): void + { + $this->templates = []; + } + + public function readTemplateFile(string $templateName): string + { + if (! isset($this->templates[$templateName])) { + throw new InvalidArgumentException("Template '{$templateName}' not found in inline templates"); + } + + return $this->templates[$templateName]; + } +} diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php new file mode 100644 index 0000000..2387ac5 --- /dev/null +++ b/app/Liquid/Filters/Data.php @@ -0,0 +1,136 @@ +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/Localization.php b/app/Liquid/Filters/Localization.php new file mode 100644 index 0000000..c91c75b --- /dev/null +++ b/app/Liquid/Filters/Localization.php @@ -0,0 +1,52 @@ +locale($locale); + } + + return $carbon->translatedFormat($format); + } + + /** + * Translate a common word to another language + * + * @param string $word The word to translate + * @param string $locale The locale to translate to + * @return string The translated word + */ + public function l_word(string $word, string $locale): string + { + $translation = trans('custom_plugins.'.mb_strtolower($word), locale: $locale); + + if ($translation === 'custom_plugins.'.mb_strtolower($word)) { + return $word; + } + + return $translation; + } +} diff --git a/app/Liquid/Filters/Numbers.php b/app/Liquid/Filters/Numbers.php new file mode 100644 index 0000000..0e31de1 --- /dev/null +++ b/app/Liquid/Filters/Numbers.php @@ -0,0 +1,50 @@ +convert($markdown); + } catch (CommonMarkException $e) { + Log::error('Markdown conversion error: '.$e->getMessage()); + } + + return null; + } + + /** + * Strip HTML tags from a string + * + * @param string $html The HTML string to strip + * @return string The string without HTML tags + */ + public function strip_html(string $html): string + { + return strip_tags($html); + } +} diff --git a/app/Liquid/Filters/Uniqueness.php b/app/Liquid/Filters/Uniqueness.php new file mode 100644 index 0000000..35378b3 --- /dev/null +++ b/app/Liquid/Filters/Uniqueness.php @@ -0,0 +1,43 @@ +generateRandomString(); + } + + /** + * Generate a random string + * + * @param int $length The length of the random string + * @return string A random string + */ + private function generateRandomString(int $length = 4): string + { + $characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + $randomString = ''; + + for ($i = 0; $i < $length; ++$i) { + $randomString .= $characters[random_int(0, mb_strlen($characters) - 1)]; + } + + return $randomString; + } +} diff --git a/app/Liquid/Tags/TemplateTag.php b/app/Liquid/Tags/TemplateTag.php new file mode 100644 index 0000000..94e08c1 --- /dev/null +++ b/app/Liquid/Tags/TemplateTag.php @@ -0,0 +1,100 @@ +params->expression(); + + $this->templateName = match (true) { + is_string($templateNameExpression) => mb_trim($templateNameExpression), + is_numeric($templateNameExpression) => (string) $templateNameExpression, + $templateNameExpression instanceof VariableLookup => (string) $templateNameExpression, + default => throw new SyntaxException('Template name must be a string, number, or variable'), + }; + + // Validate template name (letters, numbers, underscores, and slashes only) + if (! preg_match('/^[a-zA-Z0-9_\/]+$/', $this->templateName)) { + throw new SyntaxException("Invalid template name '{$this->templateName}' - template names must contain only letters, numbers, underscores, and slashes"); + } + + $context->params->assertEnd(); + + assert($context->body instanceof BodyNode); + + $body = $context->body->children()[0] ?? null; + $this->body = match (true) { + $body instanceof Raw => $body, + default => throw new SyntaxException('template tag must have a single raw body'), + }; + + // Register the template with the file system during parsing + $fileSystem = $context->getParseContext()->environment->fileSystem; + if ($fileSystem instanceof InlineTemplatesFileSystem) { + // Store the raw content for later rendering + $fileSystem->register($this->templateName, $this->body->value); + } + + return $this; + } + + public function render(RenderContext $context): string + { + // Get the file system from the environment + $fileSystem = $context->environment->fileSystem; + + if (! $fileSystem instanceof InlineTemplatesFileSystem) { + // If no inline file system is available, just return empty string + // This allows the template to be used in contexts where inline templates aren't supported + return ''; + } + + // Register the template with the file system + $fileSystem->register($this->templateName, $this->body->render($context)); + + // Return empty string as template tags don't output anything + return ''; + } + + public function getTemplateName(): string + { + return $this->templateName; + } + + public function getBody(): Raw + { + return $this->body; + } +} diff --git a/app/Liquid/Utils/ExpressionUtils.php b/app/Liquid/Utils/ExpressionUtils.php new file mode 100644 index 0000000..8a5bdb0 --- /dev/null +++ b/app/Liquid/Utils/ExpressionUtils.php @@ -0,0 +1,210 @@ + '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 c16322c..183add4 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() + public function mount(): void { $this->deviceAutojoin = auth()->user()->assign_new_devices; $this->isFirstUser = auth()->user()->id === 1; } - public function updating($name, $value) + public function updating($name, $value): void { $this->validate([ 'deviceAutojoin' => 'boolean', @@ -30,7 +30,7 @@ class DeviceAutoJoin extends Component } } - public function render() + public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory { return view('livewire.actions.device-auto-join'); } diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php index 45993bb..c26fa72 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() + public function __invoke(): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse { Auth::guard('web')->logout(); diff --git a/app/Livewire/DeviceDashboard.php b/app/Livewire/DeviceDashboard.php index 78309cb..a2a3692 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() + public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory { return view('livewire.device-dashboard', ['devices' => auth()->user()->devices()->paginate(10)]); } diff --git a/app/Models/Device.php b/app/Models/Device.php index d4b5745..3583f48 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -2,21 +2,49 @@ namespace App\Models; +use Carbon\Carbon; +use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +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', 'last_log_request' => 'json', + 'proxy_cloud_response' => 'json', + 'width' => 'integer', + 'height' => 'integer', + 'rotate' => 'integer', + 'last_refreshed_at' => 'datetime', + 'sleep_mode_enabled' => 'boolean', + 'sleep_mode_from' => 'datetime:H:i', + 'sleep_mode_to' => 'datetime:H:i', + 'special_function' => 'string', + 'pause_until' => 'datetime', ]; - public function getBatteryPercentAttribute() + public function getBatteryPercentAttribute(): int|float { $volts = $this->last_battery_voltage; @@ -27,7 +55,8 @@ class Device extends Model // Ensure the voltage is within range if ($volts <= $min_volt) { return 0; - } elseif ($volts >= $max_volt) { + } + if ($volts >= $max_volt) { return 100; } @@ -37,17 +66,221 @@ class Device extends Model return round($percent); } - public function getWifiStrenghAttribute() + /** + * Calculate battery voltage from percentage + * + * @param int $percent Battery percentage (0-100) + * @return float Calculated voltage + */ + public function calculateVoltageFromPercent(int $percent): float + { + // Define min and max voltage for Li-ion battery (3.0V empty, 4.2V full) + $min_volt = 3.0; + $max_volt = 4.2; + + // Ensure the percentage is within range + if ($percent <= 0) { + return $min_volt; + } + if ($percent >= 100) { + return $max_volt; + } + + // Calculate voltage + $voltage = $min_volt + (($percent / 100) * ($max_volt - $min_volt)); + + return round($voltage, 2); + } + + public function getWifiStrengthAttribute(): int { $rssi = $this->last_rssi_level; if ($rssi >= 0) { return 0; // No signal (0 bars) - } elseif ($rssi <= -80) { + } + if ($rssi <= -80) { return 1; // Weak signal (1 bar) - } elseif ($rssi <= -60) { + } + if ($rssi <= -60) { return 2; // Moderate signal (2 bars) - } else { - return 3; // Strong signal (3 bars) + } + + return 3; // Strong signal (3 bars) + + } + + public function getUpdateFirmwareAttribute(): bool + { + if ($this->update_firmware_id) { + return true; + } + + return $this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware']; + } + + public function getFirmwareUrlAttribute(): ?string + { + if ($this->update_firmware_id) { + $firmware = Firmware::find($this->update_firmware_id); + if ($firmware) { + if ($firmware->storage_location) { + return Storage::disk('public')->url($firmware->storage_location); + } + + return $firmware->url; + } + } + + if ($this->proxy_cloud_response && $this->proxy_cloud_response['firmware_url']) { + return $this->proxy_cloud_response['firmware_url']; + } + + return null; + } + + public function resetUpdateFirmwareFlag(): void + { + if ($this->proxy_cloud_response) { + $this->proxy_cloud_response = array_merge($this->proxy_cloud_response, ['update_firmware' => false]); + $this->save(); + } + if ($this->update_firmware_id) { + $this->update_firmware_id = null; + $this->save(); } } + + public function playlists(): HasMany + { + return $this->hasMany(Playlist::class); + } + + public function getNextPlaylistItem(): ?PlaylistItem + { + // Get all active playlists + /** @var \Illuminate\Support\Collection|Playlist[] $playlists */ + $playlists = $this->playlists() + ->where('is_active', true) + ->get(); + + // Find the first active playlist with an available item + foreach ($playlists as $playlist) { + if ($playlist->isActiveNow()) { + $nextItem = $playlist->getNextPlaylistItem(); + if ($nextItem) { + return $nextItem; + } + } + } + + return null; + } + + public function playlist(): BelongsTo + { + return $this->belongsTo(Playlist::class); + } + + public function mirrorDevice(): BelongsTo + { + return $this->belongsTo(self::class, 'mirror_device_id'); + } + + public function updateFirmware(): BelongsTo + { + return $this->belongsTo(Firmware::class, 'update_firmware_id'); + } + + public function deviceModel(): BelongsTo + { + 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); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isSleepModeActive(?DateTimeInterface $now = null): bool + { + if (! $this->sleep_mode_enabled || ! $this->sleep_mode_from || ! $this->sleep_mode_to) { + return false; + } + + $now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now(); + + // Handle overnight ranges (e.g. 22:00 to 06:00) + return $this->sleep_mode_from < $this->sleep_mode_to + ? $now->between($this->sleep_mode_from, $this->sleep_mode_to) + : ($now->gte($this->sleep_mode_from) || $now->lte($this->sleep_mode_to)); + } + + public function getSleepModeEndsInSeconds(?DateTimeInterface $now = null): ?int + { + if (! $this->sleep_mode_enabled || ! $this->sleep_mode_from || ! $this->sleep_mode_to) { + return null; + } + + $now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now(); + $from = $this->sleep_mode_from; + $to = $this->sleep_mode_to; + + // Handle overnight ranges (e.g. 22:00 to 06:00) + if ($from < $to) { + // Normal range, same day + return $now->between($from, $to) ? (int) $now->diffInSeconds($to, false) : null; + } + // Overnight range + if ($now->gte($from)) { + // After 'from', before midnight + return (int) $now->diffInSeconds($to->copy()->addDay(), false); + } + if ($now->lt($to)) { + // After midnight, before 'to' + return (int) $now->diffInSeconds($to, false); + } + + // Not in sleep window + return null; + + } + + public function isPauseActive(): bool + { + return $this->pause_until && $this->pause_until->isFuture(); + } } diff --git a/app/Models/DeviceLog.php b/app/Models/DeviceLog.php new file mode 100644 index 0000000..6f266ce --- /dev/null +++ b/app/Models/DeviceLog.php @@ -0,0 +1,27 @@ +belongsTo(Device::class); + } + + protected function casts(): array + { + return [ + 'log_entry' => 'array', + 'device_timestamp' => 'datetime', + ]; + } +} diff --git a/app/Models/DeviceModel.php b/app/Models/DeviceModel.php new file mode 100644 index 0000000..6132a76 --- /dev/null +++ b/app/Models/DeviceModel.php @@ -0,0 +1,78 @@ + 'integer', + 'height' => 'integer', + 'colors' => 'integer', + 'bit_depth' => 'integer', + 'scale_factor' => 'float', + 'rotation' => 'integer', + 'offset_x' => 'integer', + '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 new file mode 100644 index 0000000..54b0876 --- /dev/null +++ b/app/Models/DevicePalette.php @@ -0,0 +1,23 @@ + 'integer', + 'colors' => 'array', + ]; +} diff --git a/app/Models/Firmware.php b/app/Models/Firmware.php new file mode 100644 index 0000000..63db578 --- /dev/null +++ b/app/Models/Firmware.php @@ -0,0 +1,25 @@ + 'boolean', + ]; + } + + public static function getLatest(): ?self + { + return self::where('latest', true)->first(); + } +} diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php new file mode 100644 index 0000000..b4daf5e --- /dev/null +++ b/app/Models/Playlist.php @@ -0,0 +1,115 @@ + 'boolean', + 'weekdays' => 'array', + 'active_from' => 'datetime:H:i', + 'active_until' => 'datetime:H:i', + 'refresh_time' => 'integer', + ]; + + public function device(): BelongsTo + { + return $this->belongsTo(Device::class); + } + + public function items(): HasMany + { + return $this->hasMany(PlaylistItem::class); + } + + public function isActiveNow(): bool + { + if (! $this->is_active) { + 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; + } + + 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); + + // Handle time ranges that span across midnight + if ($activeFrom > $activeUntil) { + // Time range spans midnight (e.g., 09:01 to 03:58) + if ($now >= $activeFrom || $now <= $activeUntil) { + return true; + } + } elseif ($now >= $activeFrom && $now <= $activeUntil) { + return true; + } + + return false; + } + + return true; + } + + public function getNextPlaylistItem(): ?PlaylistItem + { + if (! $this->isActiveNow()) { + return null; + } + + // Get active playlist items ordered by display order + /** @var \Illuminate\Support\Collection|PlaylistItem[] $playlistItems */ + $playlistItems = $this->items() + ->where('is_active', true) + ->orderBy('order') + ->get(); + + if ($playlistItems->isEmpty()) { + return null; + } + + // Get the last displayed item + $lastDisplayed = $playlistItems + ->sortByDesc('last_displayed_at') + ->first(); + + if (! $lastDisplayed || ! $lastDisplayed->last_displayed_at) { + // If no item has been displayed yet, return the first one + return $playlistItems->first(); + } + + // Find the next item in sequence + $currentOrder = $lastDisplayed->order; + $nextItem = $playlistItems + ->where('order', '>', $currentOrder) + ->first(); + + // If there's no next item, loop back to the first one + if (! $nextItem) { + return $playlistItems->first(); + } + + return $nextItem; + } +} diff --git a/app/Models/PlaylistItem.php b/app/Models/PlaylistItem.php new file mode 100644 index 0000000..ad11f1d --- /dev/null +++ b/app/Models/PlaylistItem.php @@ -0,0 +1,218 @@ + 'boolean', + 'last_displayed_at' => 'datetime', + 'mashup' => 'json', + ]; + + public function playlist(): BelongsTo + { + return $this->belongsTo(Playlist::class); + } + + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } + + /** + * Check if this playlist item is a mashup + */ + public function isMashup(): bool + { + return ! is_null($this->mashup); + } + + /** + * Get the mashup name if this is a mashup + */ + public function getMashupName(): ?string + { + return $this->mashup['mashup_name'] ?? null; + } + + /** + * Get the mashup layout type if this is a mashup + */ + public function getMashupLayoutType(): ?string + { + return $this->mashup['mashup_layout'] ?? null; + } + + /** + * Get all plugin IDs for this mashup + */ + public function getMashupPluginIds(): array + { + return $this->mashup['plugin_ids'] ?? []; + } + + /** + * Get the number of plugins required for the current layout + */ + public function getRequiredPluginCount(): int + { + if (! $this->isMashup()) { + return 1; + } + + return match ($this->getMashupLayoutType()) { + '1Lx1R', '1Tx1B' => 2, // Left-Right or Top-Bottom split + '1Lx2R', '2Lx1R', '2Tx1B', '1Tx2B' => 3, // Two on one side, one on other + '2x2' => 4, // Quadrant + default => 1, + }; + } + + /** + * Get the layout type (horizontal, vertical, or grid) + */ + public function getLayoutType(): string + { + if (! $this->isMashup()) { + return 'single'; + } + + return match ($this->getMashupLayoutType()) { + '1Lx1R', '1Lx2R', '2Lx1R' => 'vertical', + '1Tx1B', '2Tx1B', '1Tx2B' => 'horizontal', + '2x2' => 'grid', + default => 'single', + }; + } + + /** + * Get the layout size for a plugin based on its position + */ + public function getLayoutSize(int $position = 0): string + { + if (! $this->isMashup()) { + return 'full'; + } + + return match ($this->getMashupLayoutType()) { + '1Lx1R' => 'half_vertical', // Both sides are single plugins + '1Tx1B' => 'half_horizontal', // Both sides are single plugins + '2Lx1R' => match ($position) { + 0, 1 => 'quadrant', // Left side has 2 plugins + 2 => 'half_vertical', // Right side has 1 plugin + default => 'full' + }, + '1Lx2R' => match ($position) { + 0 => 'half_vertical', // Left side has 1 plugin + 1, 2 => 'quadrant', // Right side has 2 plugins + default => 'full' + }, + '2Tx1B' => match ($position) { + 0, 1 => 'quadrant', // Top side has 2 plugins + 2 => 'half_horizontal', // Bottom side has 1 plugin + default => 'full' + }, + '1Tx2B' => match ($position) { + 0 => 'half_horizontal', // Top side has 1 plugin + 1, 2 => 'quadrant', // Bottom side has 2 plugins + default => 'full' + }, + '2x2' => 'quadrant', // All positions are quadrants + default => 'full' + }; + } + + /** + * Render all plugins with appropriate layout + */ + public function render(?Device $device = null): 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'), + ])->render(); + } + + $pluginMarkups = []; + $pluginIds = $this->getMashupPluginIds(); + $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(); + + foreach ($plugins as $index => $plugin) { + $size = $this->getLayoutSize($index); + $pluginMarkups[] = $plugin->render($size, false); + } + + return view('trmnl-layouts.mashup', [ + 'colorDepth' => $device?->colorDepth(), + 'deviceVariant' => $device?->deviceVariant() ?? 'og', + 'scaleLevel' => $device?->scaleLevel(), + 'mashupLayout' => $this->getMashupLayoutType(), + 'slot' => implode('', $pluginMarkups), + ])->render(); + } + + /** + * Available mashup layouts with their descriptions + */ + public static function getAvailableLayouts(): array + { + return [ + '1Lx1R' => '1 Left - 1 Right (2 plugins)', + '1Lx2R' => '1 Left - 2 Right (3 plugins)', + '2Lx1R' => '2 Left - 1 Right (3 plugins)', + '1Tx1B' => '1 Top - 1 Bottom (2 plugins)', + '2Tx1B' => '2 Top - 1 Bottom (3 plugins)', + '1Tx2B' => '1 Top - 2 Bottom (3 plugins)', + '2x2' => 'Quadrant (4 plugins)', + ]; + } + + /** + * Get the required number of plugins for a given layout + */ + public static function getRequiredPluginCountForLayout(string $layout): int + { + return match ($layout) { + '1Lx1R', '1Tx1B' => 2, + '1Lx2R', '2Lx1R', '2Tx1B', '1Tx2B' => 3, + '2x2' => 4, + default => 1, + }; + } + + /** + * Create a new mashup with the given layout and plugins + */ + public static function createMashup(Playlist $playlist, string $layout, array $pluginIds, string $name, $order): self + { + return static::create([ + 'playlist_id' => $playlist->id, + 'plugin_id' => $pluginIds[0], // First plugin is the main plugin + 'mashup' => [ + 'mashup_layout' => $layout, + 'mashup_name' => $name, + 'plugin_ids' => $pluginIds, + ], + 'is_active' => true, + 'order' => $order, + ]); + } +} diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php new file mode 100644 index 0000000..68f8e7e --- /dev/null +++ b/app/Models/Plugin.php @@ -0,0 +1,642 @@ + 'json', + 'data_payload_updated_at' => 'datetime', + 'is_native' => 'boolean', + '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 { + 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() + { + 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'])) { + return false; + } + + foreach ($this->configuration_template['custom_fields'] as $field) { + // Skip fields as they are informational only + if ($field['field_type'] === 'author_bio') { + continue; + } + + if ($field['field_type'] === 'copyable') { + continue; + } + + if ($field['field_type'] === 'copyable_webhook_url') { + continue; + } + + $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; + + // Check if field is required (not marked as optional) + $isRequired = ! isset($field['optional']) || $field['optional'] !== true; + + if ($isRequired) { + $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'])) { + return true; // Found a required field that is not set and has no default + } + } + } + + return false; // All required fields are set + } + + 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()); + } + if (! $this->data_payload_updated_at || ! $this->data_stale_minutes) { + return true; + } + + return $this->data_payload_updated_at->addMinutes($this->data_stale_minutes)->isPast(); + } + + public function updateDataPayload(): void + { + if ($this->data_strategy !== 'polling' || ! $this->polling_url) { + return; + } + $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; + + // 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]); + } + } + } + + // 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) + )); + + $combinedResponse = []; + + // 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; + } + } 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; + + $this->update([ + 'data_payload' => $finalPayload, + 'data_payload_updated_at' => now(), + ]); + } + + 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; + } + } catch (Exception $e) { + Log::warning("Failed to parse {$parserName} response: ".$e->getMessage()); + } + } + + return ['error' => 'Failed to parse response']; + } + + /** + * Apply Liquid template replacements (converts 'with' syntax to comma syntax) + */ + private function applyLiquidReplacements(string $template): string + { + + $replacements = []; + + // 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', + '{% render $1, ', + $template + ); + + // Convert for loops with filters to use temporary variables + // 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(); + + 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 + { + 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'); + + // Calculate UTC offset in seconds + $utcOffset = (string) Carbon::now($timezone)->getOffset(); + + // 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 : []), + ], + ], + ], + ]; + + // 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); + } + } else { + $renderedContent = Blade::render($this->render_markup, [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ]); + } + + 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(), + 'slot' => $renderedContent, + ])->render(); + + } + + return $renderedContent; + } + + 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, + ])->render(); + } + + return view($this->render_markup_view, [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ])->render(); + + } + + return '

No render markup yet defined for this plugin.

'; + } + + /** + * Get a configuration value by key + */ + public function getConfiguration(string $key, $default = null) + { + 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 7e7da3b..c6d39b8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,6 +25,9 @@ class User extends Authenticatable // implements MustVerifyEmail 'email', 'password', 'assign_new_devices', + 'assign_new_device_id', + 'oidc_sub', + 'timezone', ]; /** @@ -66,4 +69,14 @@ class User extends Authenticatable // implements MustVerifyEmail { return $this->hasMany(Device::class); } + + public function plugins(): HasMany + { + return $this->hasMany(Plugin::class); + } + + public function routeNotificationForWebhook(): ?string + { + return config('services.webhook.notifications.url'); + } } diff --git a/app/Notifications/BatteryLow.php b/app/Notifications/BatteryLow.php new file mode 100644 index 0000000..17fb1da --- /dev/null +++ b/app/Notifications/BatteryLow.php @@ -0,0 +1,66 @@ + + */ + public function via(object $notifiable): array + { + return ['mail', WebhookChannel::class]; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage)->markdown('mail.battery-low', ['device' => $this->device]); + } + + public function toWebhook(object $notifiable): WebhookMessage + { + return WebhookMessage::create() + ->data([ + 'topic' => config('services.webhook.notifications.topic', 'battery.low'), + 'message' => "Battery below {$this->device->battery_percent}% on device: {$this->device->name}", + 'device_id' => $this->device->id, + 'device_name' => $this->device->name, + 'battery_percent' => $this->device->battery_percent, + + ]) + ->userAgent(config('app.name')) + ->header('X-TrmnlByos-Event', 'battery.low'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'device_name' => $this->device->name, + 'battery_percent' => $this->device->battery_percent, + ]; + } +} diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php new file mode 100644 index 0000000..796cb24 --- /dev/null +++ b/app/Notifications/Channels/WebhookChannel.php @@ -0,0 +1,54 @@ +routeNotificationFor('webhook', $notification); + + if (! $url) { + return null; + } + + if (! method_exists($notification, 'toWebhook')) { + throw new Exception('Notification does not implement toWebhook method.'); + } + + $webhookData = $notification->toWebhook($notifiable)->toArray(); + $response = $this->client->post($url, [ + 'query' => Arr::get($webhookData, 'query'), + 'body' => json_encode(Arr::get($webhookData, 'data')), + 'verify' => Arr::get($webhookData, 'verify'), + 'headers' => Arr::get($webhookData, 'headers'), + ]); + + if (! $response instanceof Response) { + throw new Exception('Webhook request did not return a valid GuzzleHttp\Psr7\Response.'); + } + + if ($response->getStatusCode() >= 300 || $response->getStatusCode() < 200) { + throw new Exception('Webhook request failed with status code: '.$response->getStatusCode()); + } + + return $response; + } +} diff --git a/app/Notifications/Messages/WebhookMessage.php b/app/Notifications/Messages/WebhookMessage.php new file mode 100644 index 0000000..6dc58eb --- /dev/null +++ b/app/Notifications/Messages/WebhookMessage.php @@ -0,0 +1,122 @@ +query = $query; + + return $this; + } + + /** + * Set the Webhook data to be JSON encoded. + * + * @param mixed $data + * @return $this + */ + public function data($data): self + { + $this->data = $data; + + return $this; + } + + /** + * Add a Webhook request custom header. + * + * @param string $name + * @param string $value + * @return $this + */ + public function header($name, $value): self + { + $this->headers[$name] = $value; + + return $this; + } + + /** + * Set the Webhook request UserAgent. + * + * @param string $userAgent + * @return $this + */ + public function userAgent($userAgent): self + { + $this->headers['User-Agent'] = $userAgent; + + return $this; + } + + /** + * Indicate that the request should be verified. + * + * @return $this + */ + public function verify($value = true): self + { + $this->verify = $value; + + return $this; + } + + public function toArray(): array + { + return [ + 'query' => $this->query, + 'data' => $this->data, + 'headers' => $this->headers, + 'verify' => $this->verify, + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6609fa8..b8ad9bb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,11 @@ namespace App\Providers; +use App\Services\OidcProvider; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; +use Laravel\Socialite\Facades\Socialite; class AppServiceProvider extends ServiceProvider { @@ -20,7 +24,33 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { if (app()->isProduction() && config('app.force_https')) { - \URL::forceScheme('https'); + URL::forceScheme('https'); } + + Request::macro('hasValidSignature', function ($absolute = true, array $ignoreQuery = []) { + $https = clone $this; + $https->server->set('HTTPS', 'on'); + + $http = clone $this; + $http->server->set('HTTPS', 'off'); + if (URL::hasValidSignature($https, $absolute, $ignoreQuery)) { + return true; + } + + return 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', []); + + return new OidcProvider( + $app->make(Request::class), + $config['client_id'] ?? null, + $config['client_secret'] ?? null, + $config['redirect'] ?? null, + $config['scopes'] ?? ['openid', 'profile', 'email'] + ); + }); } } diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php new file mode 100644 index 0000000..405ea3f --- /dev/null +++ b/app/Services/ImageGenerationService.php @@ -0,0 +1,541 @@ +find($deviceId); + $uuid = self::generateImageFromModel( + markup: $markup, + deviceModel: $device->deviceModel, + user: $device->user, + palette: $device->palette ?? $device->deviceModel?->palette, + device: $device + ); + + $device->update(['current_screen_image' => $uuid]); + Log::info("Device $device->id: updated with new image: $uuid"); + + 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 + */ + private static function getImageSettings(Device $device): array + { + // If device has a DeviceModel, use its settings + if ($device->deviceModel) { + return self::getImageSettingsFromModel($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), + 'use_model_settings' => true, + ]; + } + + // Default settings if no device model provided + return [ + 'width' => 800, + 'height' => 480, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'image_format' => ImageFormat::AUTO->value, + 'use_model_settings' => false, + ]; + } + + /** + * Determine the appropriate ImageFormat based on DeviceModel settings + */ + private static function determineImageFormatFromModel(DeviceModel $model): string + { + // Map DeviceModel settings to ImageFormat + if ($model->mime_type === 'image/bmp' && $model->bit_depth === 1) { + return ImageFormat::BMP3_1BIT_SRGB->value; + } + if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 2) { + return ImageFormat::PNG_8BIT_GRAYSCALE->value; + } + if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 256) { + return ImageFormat::PNG_8BIT_256C->value; + } + if ($model->mime_type === 'image/png' && $model->bit_depth === 2 && $model->colors === 4) { + return ImageFormat::PNG_2BIT_4C->value; + } + + // Default to AUTO for unknown combinations + return ImageFormat::AUTO->value; + } + + /** + * Get MIME type from ImageFormat + */ + private static function getMimeTypeFromImageFormat(string $imageFormat): string + { + 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', + }; + } + + /** + * 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; + } + + // Find (or with single quotes) and inspect class tokens + $imgWithClassPattern = '/]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i'; + if (! preg_match_all($imgWithClassPattern, $markup, $matches)) { + return false; + } + + foreach ($matches[2] as $classValue) { + // Look for class token 'image-dither' or 'image--dither' + if (preg_match('/(?:^|\s)image--?dither(?:\s|$)/', $classValue)) { + return true; + } + } + + return false; + } + + public static function cleanupFolder(): void + { + $activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray(); + $activePluginImageUuids = Plugin::pluck('current_image')->filter()->toArray(); + $activeImageUuids = array_merge($activeDeviceImageUuids, $activePluginImageUuids); + + $files = Storage::disk('public')->files('/images/generated/'); + foreach ($files as $file) { + if (basename($file) === '.gitignore') { + continue; + } + // Get filename without path and extension + $fileUuid = pathinfo($file, PATHINFO_FILENAME); + // If the UUID is not in use by any device, move it to archive + if (! in_array($fileUuid, $activeImageUuids)) { + Storage::disk('public')->delete($file); + } + } + } + + 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 { + $query->where('width', '!=', 800) + ->orWhere('height', '!=', 480) + ->orWhere('rotate', '!=', 0); + }) + ->orWhereHas('deviceModel', function ($query): void { + // Only allow caching if all device models have standard dimensions (800x480, rotation=0) + $query->where(function ($subQuery): void { + $subQuery->where('width', '!=', 800) + ->orWhere('height', '!=', 480) + ->orWhere('rotation', '!=', 0); + }); + }) + ->exists(); + + if ($hasCustomDimensions) { + // TODO cache image per device + $plugin->update(['current_image' => null]); + Log::debug('Skip cache as devices with custom dimensions or non-standard DeviceModels exist'); + } + } + } + + /** + * 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 new file mode 100644 index 0000000..8ea2e44 --- /dev/null +++ b/app/Services/OidcProvider.php @@ -0,0 +1,158 @@ +baseUrl = str_replace('/.well-known/openid-configuration', '', $endpoint); + } else { + $this->baseUrl = mb_rtrim($endpoint, '/'); + } + + $this->scopes = $scopes ?: ['openid', 'profile', 'email']; + $this->loadOidcConfiguration(); + } + + /** + * Load OIDC configuration from the well-known endpoint. + */ + protected function loadOidcConfiguration() + { + try { + $url = $this->baseUrl.'/.well-known/openid-configuration'; + $client = app(Client::class); + $response = $client->get($url); + $this->oidcConfig = json_decode($response->getBody()->getContents(), true); + + if (! $this->oidcConfig) { + throw new Exception('OIDC configuration is empty or invalid JSON'); + } + + if (! isset($this->oidcConfig['authorization_endpoint'])) { + throw new Exception('authorization_endpoint not found in OIDC configuration'); + } + + } catch (Exception $e) { + throw new Exception('Failed to load OIDC configuration: '.$e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Get the authentication URL for the provider. + */ + protected function getAuthUrl($state) + { + if (! $this->oidcConfig || ! isset($this->oidcConfig['authorization_endpoint'])) { + throw new Exception('OIDC configuration not loaded or authorization_endpoint not found.'); + } + + return $this->buildAuthUrlFromBase($this->oidcConfig['authorization_endpoint'], $state); + } + + /** + * Get the token URL for the provider. + */ + protected function getTokenUrl() + { + if (! $this->oidcConfig || ! isset($this->oidcConfig['token_endpoint'])) { + throw new Exception('OIDC configuration not loaded or token_endpoint not found.'); + } + + return $this->oidcConfig['token_endpoint']; + } + + /** + * Get the raw user for the given access token. + */ + protected function getUserByToken($token) + { + if (! $this->oidcConfig || ! isset($this->oidcConfig['userinfo_endpoint'])) { + throw new Exception('OIDC configuration not loaded or userinfo_endpoint not found.'); + } + + $response = $this->getHttpClient()->get($this->oidcConfig['userinfo_endpoint'], [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + ], + ]); + + return json_decode($response->getBody(), true); + } + + /** + * Map the raw user array to a Socialite User instance. + */ + public function mapUserToObject(array $user) + { + return (new User)->setRaw($user)->map([ + 'id' => $user['sub'], + 'nickname' => $user['preferred_username'] ?? null, + 'name' => $user['name'] ?? null, + 'email' => $user['email'] ?? null, + 'avatar' => $user['picture'] ?? null, + ]); + } + + /** + * Get the access token response for the given code. + */ + public function getAccessTokenResponse($code) + { + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + 'headers' => ['Accept' => 'application/json'], + 'form_params' => $this->getTokenFields($code), + ]); + + return json_decode($response->getBody(), true); + } + + /** + * Get the POST fields for the token request. + */ + protected function getTokenFields($code) + { + return array_merge(parent::getTokenFields($code), [ + 'grant_type' => 'authorization_code', + ]); + } +} diff --git a/app/Services/Plugin/Parsers/IcalResponseParser.php b/app/Services/Plugin/Parsers/IcalResponseParser.php new file mode 100644 index 0000000..c8f2b58 --- /dev/null +++ b/app/Services/Plugin/Parsers/IcalResponseParser.php @@ -0,0 +1,111 @@ +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 new file mode 100644 index 0000000..44ea0cb --- /dev/null +++ b/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..b8f9c05 --- /dev/null +++ b/app/Services/Plugin/Parsers/ResponseParser.php @@ -0,0 +1,15 @@ + + */ + 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 new file mode 100644 index 0000000..b82ba80 --- /dev/null +++ b/app/Services/Plugin/Parsers/XmlResponseParser.php @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..241764d --- /dev/null +++ b/app/Services/PluginExportService.php @@ -0,0 +1,172 @@ +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'); + } + + /** + * Generate the settings.yml content for the plugin + */ + private function generateSettingsYaml(Plugin $plugin): array + { + $settings = []; + + // Add fields in the specific order requested + $settings['name'] = $plugin->name; + $settings['no_screen_padding'] = 'no'; // Default value + $settings['dark_mode'] = 'no'; // Default value + $settings['strategy'] = $plugin->data_strategy; + + // Add static data if available + if ($plugin->data_payload) { + $settings['static_data'] = json_encode($plugin->data_payload, JSON_PRETTY_PRINT); + } + + // Add polling configuration if applicable + if ($plugin->data_strategy === 'polling') { + if ($plugin->polling_verb) { + $settings['polling_verb'] = $plugin->polling_verb; + } + if ($plugin->polling_url) { + $settings['polling_url'] = $plugin->polling_url; + } + if ($plugin->polling_header) { + // Convert header format from "key: value" to "key=value" + $settings['polling_headers'] = str_replace(':', '=', $plugin->polling_header); + } + if ($plugin->polling_body) { + $settings['polling_body'] = $plugin->polling_body; + } + } + + $settings['refresh_interval'] = $plugin->data_stale_minutes; + $settings['id'] = $plugin->trmnlp_id; + + // Add custom fields from configuration template + if (isset($plugin->configuration_template['custom_fields'])) { + $settings['custom_fields'] = $plugin->configuration_template['custom_fields']; + } + + return $settings; + } + + /** + * Generate the full template content + */ + private function generateFullTemplate(Plugin $plugin): string + { + $markup = $plugin->render_markup; + + // Remove the wrapper div if it exists (it will be added during import) + $markup = preg_replace('/^
\s*/', '', $markup); + $markup = preg_replace('/\s*<\/div>\s*$/', '', $markup); + + return mb_trim($markup); + } + + /** + * Generate the shared template content (for liquid templates) + */ + private function generateSharedTemplate(): null + { + // For now, we don't have a way to store shared templates separately + // TODO - add support for shared templates + return null; + } + + /** + * Add a directory and its contents to a ZIP file + */ + private function addDirectoryToZip(ZipArchive $zip, string $dirPath, string $zipPath): void + { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dirPath), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $file) { + if (! $file->isDir()) { + $filePath = $file->getRealPath(); + $fileName = basename((string) $filePath); + + // For root directory, just use the filename + $relativePath = $zipPath === '' ? $fileName : $zipPath.'/'.mb_substr((string) $filePath, mb_strlen($dirPath) + 1); + + $zip->addFile($filePath, $relativePath); + } + } + } +} diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php new file mode 100644 index 0000000..49dce99 --- /dev/null +++ b/app/Services/PluginImportService.php @@ -0,0 +1,598 @@ +getRealPath(); + + // Extract the ZIP file using ZipArchive + $zip = new ZipArchive(); + if ($zip->open($zipFullPath) !== true) { + throw new Exception('Could not open the 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'], + ]; + + $plugin_updated = isset($settings['id']) + && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists(); + // Create a new plugin + $plugin = Plugin::updateOrCreate( + [ + 'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + ], + [ + 'user_id' => $user->id, + 'name' => $settings['name'] ?? 'Imported Plugin', + 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + 'data_stale_minutes' => $settings['refresh_interval'] ?? 15, + 'data_strategy' => $settings['strategy'] ?? 'static', + 'polling_url' => $settings['polling_url'] ?? null, + 'polling_verb' => $settings['polling_verb'] ?? 'get', + 'polling_header' => isset($settings['polling_headers']) + ? str_replace('=', ':', $settings['polling_headers']) + : null, + 'polling_body' => $settings['polling_body'] ?? null, + 'markup_language' => $markupLanguage, + 'render_markup' => $fullLiquid, + 'configuration_template' => $configurationTemplate, + 'data_payload' => json_decode($settings['static_data'] ?? '{}', true), + ]); + + if (! $plugin_updated) { + // Extract default values from custom_fields and populate configuration + $configuration = []; + foreach ($settings['custom_fields'] as $field) { + if (isset($field['keyname']) && isset($field['default'])) { + $configuration[$field['keyname']] = $field['default']; + } + } + // set only if plugin is new + $plugin->update([ + 'configuration' => $configuration, + ]); + } + $plugin['trmnlp_yaml'] = $settingsYaml; + + return $plugin; + + } finally { + // Clean up temporary directory + Storage::deleteDirectory($tempDirName); + } + } + + /** + * Import a plugin from a ZIP URL + * + * @param string $zipUrl The URL to the ZIP file + * @param User $user The user importing the plugin + * @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 + { + $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')) { + $settingsYamlPath = $tempDir.'/src/settings.yml'; + + // Check for full.liquid or full.blade.php + if (File::exists($tempDir.'/src/full.liquid')) { + $fullLiquidPath = $tempDir.'/src/full.liquid'; + } elseif (File::exists($tempDir.'/src/full.blade.php')) { + $fullLiquidPath = $tempDir.'/src/full.blade.php'; + } + + // Check for shared.liquid or shared.blade.php 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 + $directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($directories); + + foreach ($files as $file) { + $filename = $file->getFilename(); + $filepath = $file->getPathname(); + + if ($filename === 'settings.yml') { + $settingsYamlPath = $filepath; + } elseif ($filename === 'full.liquid' || $filename === 'full.blade.php') { + $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 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 the files are in the root of the ZIP, create a src folder and move them there + $srcDir = dirname((string) $settingsYamlPath); + + // If the parent directory is not named 'src', create a src directory + if (basename($srcDir) !== 'src') { + $newSrcDir = $tempDir.'/src'; + File::makeDirectory($newSrcDir, 0755, true); + + // Copy the files to the src directory + File::copy($settingsYamlPath, $newSrcDir.'/settings.yml'); + + // 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 + 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'; + } + } + } + + return [ + '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 4fdd101..0ced4da 100644 --- a/composer.json +++ b/composer.json @@ -4,34 +4,45 @@ "type": "project", "description": "TRMNL Server Implementation (BYOS) for Laravel", "keywords": [ - "laravel", - "framework", - "trmnl" + "trmnl", + "trmnl-server", + "trmnl-byos", + "laravel" ], "license": "MIT", "require": { "php": "^8.2", "ext-imagick": "*", - "bnussbau/laravel-trmnl": "^0.1.4", - "intervention/image": "^3.11", + "ext-simplexml": "*", + "ext-zip": "*", + "bnussbau/laravel-trmnl-blade": "2.1.*", + "bnussbau/trmnl-pipeline-php": "^0.6.0", + "keepsuit/laravel-liquid": "^0.5.2", "laravel/framework": "^12.1", "laravel/sanctum": "^4.0", + "laravel/socialite": "^5.23", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.0", "livewire/volt": "^1.7", + "om/icalparser": "^3.2", "spatie/browsershot": "^5.0", - "spatie/pest-expectations": "^1.3" + "stevebauman/purify": "^6.3", + "symfony/yaml": "^7.3", + "wnx/sidecar-browsershot": "^2.6" }, "require-dev": { "fakerphp/faker": "^1.23", + "larastan/larastan": "^3.0", + "laravel/boost": "^1.0", "laravel/pail": "^1.2.2", "laravel/pint": "^1.18", "laravel/sail": "^1.41", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", - "pestphp/pest": "^3.7", - "pestphp/pest-plugin-drift": "^3.0", - "pestphp/pest-plugin-laravel": "^3.1" + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-drift": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "rector/rector": "^2.1" }, "autoload": { "psr-4": { @@ -64,7 +75,13 @@ "dev": [ "Composer\\Config::disableProcessTimeout", "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite" - ] + ], + "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" }, "extra": { "laravel": { diff --git a/composer.lock b/composer.lock index c498d3c..d23d014 100644 --- a/composer.lock +++ b/composer.lock @@ -4,55 +4,201 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0c76944cf73daa04570bba3d4cb342f6", + "content-hash": "25c2a1a4a2f2594adefe25ddb6a072fb", "packages": [ { - "name": "bnussbau/laravel-trmnl", - "version": "0.1.4", + "name": "aws/aws-crt-php", + "version": "v1.2.7", "source": { "type": "git", - "url": "https://github.com/bnussbau/laravel-trmnl.git", - "reference": "7c8576c75a8d3967fbc308ad246ceee04d6b65b1" + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnussbau/laravel-trmnl/zipball/7c8576c75a8d3967fbc308ad246ceee04d6b65b1", - "reference": "7c8576c75a8d3967fbc308ad246ceee04d6b65b1", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.369.10", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "e179090bf2d658be7be37afc146111966ba6f41b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e179090bf2d658be7be37afc146111966ba6f41b", + "reference": "e179090bf2d658be7be37afc146111966ba6f41b", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "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" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^9.6", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "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", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "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.10" + }, + "time": "2026-01-09T19:08:12+00:00" + }, + { + "name": "bnussbau/laravel-trmnl-blade", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/bnussbau/laravel-trmnl-blade.git", + "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/1e1cabfead00118d7a80c86ac6108aece2989bc7", + "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7", "shasum": "" }, "require": { "illuminate/contracts": "^10.0||^11.0||^12.0", "php": "^8.2", - "spatie/laravel-package-tools": "^1.18", - "voku/simple_html_dom": "^4.8" + "spatie/laravel-package-tools": "^1.18" }, "require-dev": { - "larastan/larastan": "^2.9||^3.0", "laravel/pint": "^1.14", "nunomaduro/collision": "^8.1.1||^7.10.0", - "orchestra/testbench": "10.*||^9.0.0||^8.22.0", + "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0", "pestphp/pest": "^3.0", "pestphp/pest-plugin-arch": "^3.0", - "pestphp/pest-plugin-laravel": "^3.0", - "phpstan/extension-installer": "^1.3||^2.0", - "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", - "phpstan/phpstan-phpunit": "^1.3||^2.0" + "pestphp/pest-plugin-laravel": "^3.0" }, "type": "library", "extra": { "laravel": { "aliases": { - "Trmnl": "Bnussbau\\LaravelTrmnl\\Facades\\LaravelTrmnl" + "TrmnlBlade": "Bnussbau\\TrmnlBlade\\Facades\\TrmnlBlade" }, "providers": [ - "Bnussbau\\LaravelTrmnl\\LaravelTrmnlServiceProvider" + "Bnussbau\\TrmnlBlade\\TrmnlBladeServiceProvider" ] } }, "autoload": { "psr-4": { - "Bnussbau\\LaravelTrmnl\\": "src/", - "Bnussbau\\LaravelTrmnl\\Database\\Factories\\": "database/factories/" + "Bnussbau\\TrmnlBlade\\": "src/", + "Bnussbau\\TrmnlBlade\\Database\\Factories\\": "database/factories/" } }, "notification-url": "https://packagist.org/downloads/", @@ -66,46 +212,127 @@ "role": "Developer" } ], - "description": "Develop TRMNL plugins with Laravel", - "homepage": "https://github.com/bnussbau/laravel-trmnl", + "description": "Blade Components on top of the TRMNL Design System", + "homepage": "https://github.com/bnussbau/laravel-trmnl-blade", "keywords": [ "Benjamin Nussbaum", "TRMNL", + "blade", + "design system", "laravel" ], "support": { - "issues": "https://github.com/bnussbau/laravel-trmnl/issues", - "source": "https://github.com/bnussbau/laravel-trmnl/tree/0.1.4" + "issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues", + "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.1.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-02-23T12:50:40+00:00" + "time": "2026-01-02T20:38:51+00:00" }, { - "name": "brick/math", - "version": "0.12.3", + "name": "bnussbau/trmnl-pipeline-php", + "version": "0.6.0", "source": { "type": "git", - "url": "https://github.com/brick/math.git", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + "url": "https://github.com/bnussbau/trmnl-pipeline-php.git", + "reference": "228505afa8a39a5033916e9ae5ccbf1733092c1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "url": "https://api.github.com/repos/bnussbau/trmnl-pipeline-php/zipball/228505afa8a39a5033916e9ae5ccbf1733092c1f", + "reference": "228505afa8a39a5033916e9ae5ccbf1733092c1f", "shasum": "" }, "require": { - "php": "^8.1" + "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" + }, + { + "name": "brick/math", + "version": "0.14.1", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "shasum": "" + }, + "require": { + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -135,7 +362,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.3" + "source": "https://github.com/brick/math/tree/0.14.1" }, "funding": [ { @@ -143,7 +370,7 @@ "type": "github" } ], - "time": "2025-02-28T13:11:00+00:00" + "time": "2025-11-24T14:40:29+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -291,33 +518,32 @@ }, { "name": "doctrine/inflector", - "version": "2.0.10", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5", - "vimeo/psalm": "^4.25 || ^5.4" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + "Doctrine\\Inflector\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -362,7 +588,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.0.10" + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { @@ -378,7 +604,7 @@ "type": "tidelift" } ], - "time": "2024-02-18T20:23:39+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { "name": "doctrine/lexer", @@ -459,29 +685,28 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.4.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "8c784d071debd117328803d86b2097615b457500" + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", - "reference": "8c784d071debd117328803d86b2097615b457500", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "shasum": "" }, "require": { - "php": "^7.2|^8.0", - "webmozart/assert": "^1.0" + "php": "^8.2|^8.3|^8.4|^8.5" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" }, "type": "library", "extra": { @@ -512,7 +737,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" }, "funding": [ { @@ -520,20 +745,20 @@ "type": "github" } ], - "time": "2024-10-09T13:47:03+00:00" + "time": "2025-10-31T18:51:33+00:00" }, { "name": "egulias/email-validator", - "version": "4.0.3", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/egulias/EmailValidator.git", - "reference": "b115554301161fa21467629f1e1391c1936de517" + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/b115554301161fa21467629f1e1391c1936de517", - "reference": "b115554301161fa21467629f1e1391c1936de517", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", "shasum": "" }, "require": { @@ -579,7 +804,7 @@ ], "support": { "issues": "https://github.com/egulias/EmailValidator/issues", - "source": "https://github.com/egulias/EmailValidator/tree/4.0.3" + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" }, "funding": [ { @@ -587,35 +812,159 @@ "type": "github" } ], - "time": "2024-12-27T00:36:43+00:00" + "time": "2025-03-06T22:45:56+00:00" }, { - "name": "fruitcake/php-cors", - "version": "v1.3.0", + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", "source": { "type": "git", - "url": "https://github.com/fruitcake/php-cors.git", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "symfony/http-foundation": "^4.4|^5.4|^6|^7" + "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": { - "phpstan/phpstan": "^1.4", + "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", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + }, + "time": "2025-12-16T22:17:28+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.5" + "squizlabs/php_codesniffer": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.2-dev" + "dev-master": "1.3-dev" } }, "autoload": { @@ -646,7 +995,7 @@ ], "support": { "issues": "https://github.com/fruitcake/php-cors/issues", - "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" }, "funding": [ { @@ -658,28 +1007,28 @@ "type": "github" } ], - "time": "2023-10-12T05:21:21+00:00" + "time": "2025-12-03T09:33:47+00:00" }, { "name": "graham-campbell/result-type", - "version": "v1.1.3", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.9.5" }, "require-dev": { - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" }, "type": "library", "autoload": { @@ -708,7 +1057,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, "funding": [ { @@ -720,26 +1069,26 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:45:45+00:00" + "time": "2025-12-27T19:43:20+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.9.2", + "version": "7.10.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "d281ed313b989f213357e3be1a179f02196ac99b" + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b", - "reference": "d281ed313b989f213357e3be1a179f02196ac99b", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.3", - "guzzlehttp/psr7": "^2.7.0", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -830,7 +1179,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.2" + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" }, "funding": [ { @@ -846,20 +1195,20 @@ "type": "tidelift" } ], - "time": "2024-07-24T11:22:20+00:00" + "time": "2025-08-23T22:36:01+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.0.4", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455" + "reference": "481557b130ef3790cf82b713667b43030dc9c957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455", - "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", "shasum": "" }, "require": { @@ -867,7 +1216,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "type": "library", "extra": { @@ -913,7 +1262,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.4" + "source": "https://github.com/guzzle/promises/tree/2.3.0" }, "funding": [ { @@ -929,20 +1278,20 @@ "type": "tidelift" } ], - "time": "2024-10-17T10:06:22+00:00" + "time": "2025-08-22T14:34:08+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.7.0", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201" + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201", - "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { @@ -958,7 +1307,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1029,7 +1378,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.0" + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, "funding": [ { @@ -1045,20 +1394,20 @@ "type": "tidelift" } ], - "time": "2024-07-18T11:15:46+00:00" + "time": "2025-08-23T21:21:41+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.4", + "version": "v1.0.5", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2" + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", "shasum": "" }, "require": { @@ -1067,7 +1416,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "phpunit/phpunit": "^8.5.44 || ^9.6.25", "uri-template/tests": "1.0.0" }, "type": "library", @@ -1115,7 +1464,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.4" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" }, "funding": [ { @@ -1131,35 +1480,47 @@ "type": "tidelift" } ], - "time": "2025-02-03T10:55:03+00:00" + "time": "2025-08-22T14:27:06+00:00" }, { - "name": "intervention/gif", - "version": "4.2.1", + "name": "hammerstone/sidecar", + "version": "v0.7.1", "source": { "type": "git", - "url": "https://github.com/Intervention/gif.git", - "reference": "6addac2c68b4bc0e37d0d3ccedda57eb84729c49" + "url": "https://github.com/aarondfrancis/sidecar.git", + "reference": "e30df1a441bd5a61d3da9342328926227c63610f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/gif/zipball/6addac2c68b4bc0e37d0d3ccedda57eb84729c49", - "reference": "6addac2c68b4bc0e37d0d3ccedda57eb84729c49", + "url": "https://api.github.com/repos/aarondfrancis/sidecar/zipball/e30df1a441bd5a61d3da9342328926227c63610f", + "reference": "e30df1a441bd5a61d3da9342328926227c63610f", "shasum": "" }, "require": { + "aws/aws-sdk-php": "^3.216.1", + "guzzlehttp/guzzle": "^6.5.8|^7.2", + "illuminate/console": "^8|^9|^10|^11|^12.0", + "illuminate/filesystem": "^8|^9|^10|^11|^12.0", + "illuminate/support": "^8|^9|^10|^11|^12.0", + "maennchen/zipstream-php": "^3.1", "php": "^8.1" }, "require-dev": { - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.0 || ^11.0", - "slevomat/coding-standard": "~8.0", - "squizlabs/php_codesniffer": "^3.8" + "mockery/mockery": "^1.3.3", + "orchestra/testbench": "^5|^6|^7|^8|^9|^10.0", + "phpunit/phpunit": ">=8.5.23|^9|^10" }, "type": "library", + "extra": { + "laravel": { + "providers": [ + "Hammerstone\\Sidecar\\Providers\\SidecarServiceProvider" + ] + } + }, "autoload": { "psr-4": { - "Intervention\\Gif\\": "src" + "Hammerstone\\Sidecar\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1168,72 +1529,128 @@ ], "authors": [ { - "name": "Oliver Vogel", - "email": "oliver@intervention.io", - "homepage": "https://intervention.io/" + "name": "Aaron Francis", + "email": "aaron@hammerstone.dev" } ], - "description": "Native PHP GIF Encoder/Decoder", - "homepage": "https://github.com/intervention/gif", - "keywords": [ - "animation", - "gd", - "gif", - "image" - ], + "description": "A Laravel package to deploy Lambda functions alongside your main application.", "support": { - "issues": "https://github.com/Intervention/gif/issues", - "source": "https://github.com/Intervention/gif/tree/4.2.1" + "issues": "https://github.com/aarondfrancis/sidecar/issues", + "source": "https://github.com/aarondfrancis/sidecar/tree/v0.7.1" }, - "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-01-05T10:52:39+00:00" + "time": "2025-08-22T14:58:51+00:00" }, { - "name": "intervention/image", - "version": "3.11.2", + "name": "keepsuit/laravel-liquid", + "version": "v0.5.4", "source": { "type": "git", - "url": "https://github.com/Intervention/image.git", - "reference": "ebbb711871fb261c064cf4c422f5f3c124fe1842" + "url": "https://github.com/keepsuit/laravel-liquid.git", + "reference": "ba426f44798042e3635a29ea91bbf2a4b2874a04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/ebbb711871fb261c064cf4c422f5f3c124fe1842", - "reference": "ebbb711871fb261c064cf4c422f5f3c124fe1842", + "url": "https://api.github.com/repos/keepsuit/laravel-liquid/zipball/ba426f44798042e3635a29ea91bbf2a4b2874a04", + "reference": "ba426f44798042e3635a29ea91bbf2a4b2874a04", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0 || ^11.0 || ^12.0", + "keepsuit/liquid": "^0.7 || ^0.8 || ^0.9", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.16", + "symfony/var-exporter": "^6.3 || ^7.0" + }, + "require-dev": { + "itsgoingd/clockwork": "^5.3", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.0", + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^7.8 || ^8.0 || ^9.0", + "orchestra/testbench": "^8.14 || ^9.0 || ^10.0", + "pestphp/pest": "^2.13 || ^3.0", + "pestphp/pest-plugin-arch": "^2.5 || ^3.0", + "pestphp/pest-plugin-laravel": "^2.2 || ^3.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "spatie/laravel-ray": "^1.26" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Liquid": "Keepsuit\\LaravelLiquid\\Facades\\Liquid" + }, + "providers": [ + "Keepsuit\\LaravelLiquid\\LiquidServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Keepsuit\\LaravelLiquid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabio Capucci", + "email": "f.capucci@keepsuit.com", + "role": "Developer" + } + ], + "description": "Liquid template engine for Laravel", + "homepage": "https://github.com/keepsuit/laravel-liquid", + "keywords": [ + "keepsuit", + "laravel", + "liquid" + ], + "support": { + "issues": "https://github.com/keepsuit/laravel-liquid/issues", + "source": "https://github.com/keepsuit/laravel-liquid/tree/v0.5.4" + }, + "time": "2025-06-15T12:06:40+00:00" + }, + { + "name": "keepsuit/liquid", + "version": "v0.9.1", + "source": { + "type": "git", + "url": "https://github.com/keepsuit/php-liquid.git", + "reference": "844d88540524f99d9039916e0ef688b7f222ebc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/keepsuit/php-liquid/zipball/844d88540524f99d9039916e0ef688b7f222ebc0", + "reference": "844d88540524f99d9039916e0ef688b7f222ebc0", "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." + "laravel/pint": "^1.2", + "pestphp/pest": "^2.36 || ^3.0 || ^4.0", + "pestphp/pest-plugin-arch": "^2.7 || ^3.0 || ^4.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" }, "type": "library", "autoload": { "psr-4": { - "Intervention\\Image\\": "src" + "Keepsuit\\Liquid\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1242,57 +1659,39 @@ ], "authors": [ { - "name": "Oliver Vogel", - "email": "oliver@intervention.io", - "homepage": "https://intervention.io/" + "name": "Fabio Capucci", + "email": "f.capucci@keepsuit.com", + "role": "Developer" } ], - "description": "PHP image manipulation", - "homepage": "https://image.intervention.io/", + "description": "PHP implementation of liquid markup language", + "homepage": "https://github.com/keepsuit/php-liquid", "keywords": [ - "gd", - "image", - "imagick", - "resize", - "thumbnail", - "watermark" + "keepsuit", + "liquid" ], "support": { - "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.2" + "issues": "https://github.com/keepsuit/php-liquid/issues", + "source": "https://github.com/keepsuit/php-liquid/tree/v0.9.1" }, - "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-02-27T13:08:55+00:00" + "time": "2025-12-01T12:01:51+00:00" }, { "name": "laravel/framework", - "version": "v12.1.1", + "version": "v12.46.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "9be5738f1ca1530055bb9d6db81f909a7ed34842" + "reference": "9dcff48d25a632c1fadb713024c952fec489c4ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/9be5738f1ca1530055bb9d6db81f909a7ed34842", - "reference": "9be5738f1ca1530055bb9d6db81f909a7ed34842", + "url": "https://api.github.com/repos/laravel/framework/zipball/9dcff48d25a632c1fadb713024c952fec489c4ae", + "reference": "9dcff48d25a632c1fadb713024c952fec489c4ae", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12", + "brick/math": "^0.11|^0.12|^0.13|^0.14", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -1309,7 +1708,7 @@ "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.6", + "league/commonmark": "^2.7", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", @@ -1328,7 +1727,9 @@ "symfony/http-kernel": "^7.2.0", "symfony/mailer": "^7.2.0", "symfony/mime": "^7.2.0", - "symfony/polyfill-php83": "^1.31", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", "symfony/uid": "^7.2.0", @@ -1364,6 +1765,7 @@ "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", @@ -1373,6 +1775,7 @@ "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", @@ -1396,13 +1799,14 @@ "league/flysystem-read-only": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^10.0.0", - "pda/pheanstalk": "^5.0.6", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.8.1", + "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", - "resend/resend-php": "^0.10.0", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", "symfony/cache": "^7.2.0", "symfony/http-client": "^7.2.0", "symfony/psr-http-message-bridge": "^7.2.0", @@ -1421,7 +1825,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 use the eloquent factory builder (^1.9.1).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "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).", @@ -1433,10 +1837,10 @@ "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3).", + "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).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.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).", @@ -1458,6 +1862,7 @@ "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" ], @@ -1466,7 +1871,8 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/" + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" ] } }, @@ -1490,20 +1896,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-03-05T15:31:19+00:00" + "time": "2026-01-07T23:26:53+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.5", + "version": "v0.3.8", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1" + "reference": "096748cdfb81988f60090bbb839ce3205ace0d35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/57b8f7efe40333cdb925700891c7d7465325d3b1", - "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1", + "url": "https://api.github.com/repos/laravel/prompts/zipball/096748cdfb81988f60090bbb839ce3205ace0d35", + "reference": "096748cdfb81988f60090bbb839ce3205ace0d35", "shasum": "" }, "require": { @@ -1519,9 +1925,9 @@ "require-dev": { "illuminate/collections": "^10.0|^11.0|^12.0", "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3|^3.4", - "phpstan/phpstan": "^1.11", - "phpstan/phpstan-mockery": "^1.1" + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" }, "suggest": { "ext-pcntl": "Required for the spinner to be animated." @@ -1547,22 +1953,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.5" + "source": "https://github.com/laravel/prompts/tree/v0.3.8" }, - "time": "2025-02-11T13:34:40+00:00" + "time": "2025-11-21T20:52:52+00:00" }, { "name": "laravel/sanctum", - "version": "v4.0.8", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "ec1dd9ddb2ab370f79dfe724a101856e0963f43c" + "reference": "fd447754d2d3f56950d53b930128af2e3b617de9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/ec1dd9ddb2ab370f79dfe724a101856e0963f43c", - "reference": "ec1dd9ddb2ab370f79dfe724a101856e0963f43c", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd447754d2d3f56950d53b930128af2e3b617de9", + "reference": "fd447754d2d3f56950d53b930128af2e3b617de9", "shasum": "" }, "require": { @@ -1576,9 +1982,8 @@ }, "require-dev": { "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.0|^10.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^11.3" + "orchestra/testbench": "^9.15|^10.8", + "phpstan/phpstan": "^1.10" }, "type": "library", "extra": { @@ -1613,20 +2018,20 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2025-01-26T19:34:36+00:00" + "time": "2026-01-06T23:11:51+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.3", + "version": "v2.0.7", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "f379c13663245f7aa4512a7869f62eb14095f23f" + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/f379c13663245f7aa4512a7869f62eb14095f23f", - "reference": "f379c13663245f7aa4512a7869f62eb14095f23f", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", "shasum": "" }, "require": { @@ -1635,7 +2040,7 @@ "require-dev": { "illuminate/support": "^10.0|^11.0|^12.0", "nesbot/carbon": "^2.67|^3.0", - "pestphp/pest": "^2.36|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", "symfony/var-dumper": "^6.2.0|^7.0.0" }, @@ -1674,20 +2079,92 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-02-11T15:03:05+00:00" + "time": "2025-11-21T20:52:36+00:00" }, { - "name": "laravel/tinker", - "version": "v2.10.1", + "name": "laravel/socialite", + "version": "v5.24.1", "source": { "type": "git", - "url": "https://github.com/laravel/tinker.git", - "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3" + "url": "https://github.com/laravel/socialite.git", + "reference": "25e28c14d55404886777af1d77cf030e0f633142" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3", - "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3", + "url": "https://api.github.com/repos/laravel/socialite/zipball/25e28c14d55404886777af1d77cf030e0f633142", + "reference": "25e28c14d55404886777af1d77cf030e0f633142", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4|^7.0", + "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", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2026-01-01T02:57:21+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", + "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", "shasum": "" }, "require": { @@ -1696,7 +2173,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" + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", @@ -1738,22 +2215,22 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.10.1" + "source": "https://github.com/laravel/tinker/tree/v2.11.0" }, - "time": "2025-01-27T14:24:01+00:00" + "time": "2025-12-19T19:16:45+00:00" }, { "name": "league/commonmark", - "version": "2.6.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "d990688c91cedfb69753ffc2512727ec646df2ad" + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d990688c91cedfb69753ffc2512727ec646df2ad", - "reference": "d990688c91cedfb69753ffc2512727ec646df2ad", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", "shasum": "" }, "require": { @@ -1782,7 +2259,7 @@ "symfony/process": "^5.4 | ^6.0 | ^7.0", "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", "unleashedtech/php-coding-standard": "^3.1.1", - "vimeo/psalm": "^4.24.0 || ^5.0.0" + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, "suggest": { "symfony/yaml": "v2.3+ required if using the Front Matter extension" @@ -1790,7 +2267,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.7-dev" + "dev-main": "2.9-dev" } }, "autoload": { @@ -1847,7 +2324,7 @@ "type": "tidelift" } ], - "time": "2024-12-29T14:10:59+00:00" + "time": "2025-11-26T21:48:24+00:00" }, { "name": "league/config", @@ -1933,16 +2410,16 @@ }, { "name": "league/flysystem", - "version": "3.29.1", + "version": "3.30.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319" + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/edc1bb7c86fab0776c3287dbd19b5fa278347319", - "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", "shasum": "" }, "require": { @@ -1966,13 +2443,13 @@ "composer/semver": "^3.0", "ext-fileinfo": "*", "ext-ftp": "*", - "ext-mongodb": "^1.3", + "ext-mongodb": "^1.3|^2", "ext-zip": "*", "friendsofphp/php-cs-fixer": "^3.5", "google/cloud-storage": "^1.23", "guzzlehttp/psr7": "^2.6", "microsoft/azure-storage-blob": "^1.1", - "mongodb/mongodb": "^1.2", + "mongodb/mongodb": "^1.2|^2", "phpseclib/phpseclib": "^3.0.36", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.5.11|^10.0", @@ -2010,22 +2487,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.29.1" + "source": "https://github.com/thephpleague/flysystem/tree/3.30.2" }, - "time": "2024-10-08T08:58:34+00:00" + "time": "2025-11-10T17:13:11+00:00" }, { "name": "league/flysystem-local", - "version": "3.29.0", + "version": "3.30.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27" + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/e0e8d52ce4b2ed154148453d321e97c8e931bd27", - "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d", + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d", "shasum": "" }, "require": { @@ -2059,9 +2536,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.29.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2" }, - "time": "2024-08-09T21:24:39+00:00" + "time": "2025-11-10T11:23:37+00:00" }, { "name": "league/mime-type-detection", @@ -2120,34 +2597,171 @@ "time": "2024-09-21T08:32:55+00:00" }, { - "name": "league/uri", - "version": "7.5.1", + "name": "league/oauth1-client", + "version": "v1.11.0", "source": { "type": "git", - "url": "https://github.com/thephpleague/uri.git", - "reference": "81fb5145d2644324614cc532b28efd0215bda430" + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", - "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.5", - "php": "^8.1" + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "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", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.7", + "php": "^8.1", + "psr/http-factory": "^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", @@ -2175,6 +2789,7 @@ "description": "URI manipulation library", "homepage": "https://uri.thephpleague.com", "keywords": [ + "URN", "data-uri", "file-uri", "ftp", @@ -2187,9 +2802,11 @@ "psr-7", "query-string", "querystring", + "rfc2141", "rfc3986", "rfc3987", "rfc6570", + "rfc8141", "uri", "uri-template", "url", @@ -2199,7 +2816,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.5.1" + "source": "https://github.com/thephpleague/uri/tree/7.7.0" }, "funding": [ { @@ -2207,26 +2824,25 @@ "type": "github" } ], - "time": "2024-12-08T08:40:02+00:00" + "time": "2025-12-07T16:02:06+00:00" }, { "name": "league/uri-interfaces", - "version": "7.5.0", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", "shasum": "" }, "require": { "ext-filter": "*", "php": "^8.1", - "psr/http-factory": "^1", "psr/http-message": "^1.1 || ^2.0" }, "suggest": { @@ -2234,6 +2850,7 @@ "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", @@ -2258,7 +2875,7 @@ "homepage": "https://nyamsprod.com" } ], - "description": "Common interfaces and classes for URI representation and interaction", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", "homepage": "https://uri.thephpleague.com", "keywords": [ "data-uri", @@ -2283,7 +2900,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.5.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" }, "funding": [ { @@ -2291,20 +2908,20 @@ "type": "github" } ], - "time": "2024-12-08T08:18:47+00:00" + "time": "2025-12-07T16:03:21+00:00" }, { "name": "livewire/flux", - "version": "v2.0.4", + "version": "v2.10.2", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "6b0d59040715f072982bfc92fe71414b44d45a0c" + "reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/6b0d59040715f072982bfc92fe71414b44d45a0c", - "reference": "6b0d59040715f072982bfc92fe71414b44d45a0c", + "url": "https://api.github.com/repos/livewire/flux/zipball/e7a93989788429bb6c0a908a056d22ea3a6c7975", + "reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975", "shasum": "" }, "require": { @@ -2312,10 +2929,13 @@ "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.5.19", + "livewire/livewire": "^3.7.3|^4.0", "php": "^8.1", "symfony/console": "^6.0|^7.0" }, + "conflict": { + "livewire/blaze": "<1.0.0" + }, "type": "library", "extra": { "laravel": { @@ -2352,22 +2972,22 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.0.4" + "source": "https://github.com/livewire/flux/tree/v2.10.2" }, - "time": "2025-02-28T16:35:28+00:00" + "time": "2025-12-19T02:11:45+00:00" }, { "name": "livewire/livewire", - "version": "v3.6.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "0df0a762698176d714e42e2dfed92b6b9e24b8e4" + "reference": "a5384df9fbd3eaf02e053bc49aabc8ace293fc1c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/0df0a762698176d714e42e2dfed92b6b9e24b8e4", - "reference": "0df0a762698176d714e42e2dfed92b6b9e24b8e4", + "url": "https://api.github.com/repos/livewire/livewire/zipball/a5384df9fbd3eaf02e053bc49aabc8ace293fc1c", + "reference": "a5384df9fbd3eaf02e053bc49aabc8ace293fc1c", "shasum": "" }, "require": { @@ -2422,7 +3042,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.6.1" + "source": "https://github.com/livewire/livewire/tree/v3.7.3" }, "funding": [ { @@ -2430,32 +3050,31 @@ "type": "github" } ], - "time": "2025-03-04T21:48:52+00:00" + "time": "2025-12-19T02:00:29+00:00" }, { "name": "livewire/volt", - "version": "v1.7.0", + "version": "v1.10.1", "source": { "type": "git", "url": "https://github.com/livewire/volt.git", - "reference": "94091094aa745c8636f9c7bed1e2da2d2a3f32b3" + "reference": "48cff133990c6261c63ee279fc091af6f6c6654e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/volt/zipball/94091094aa745c8636f9c7bed1e2da2d2a3f32b3", - "reference": "94091094aa745c8636f9c7bed1e2da2d2a3f32b3", + "url": "https://api.github.com/repos/livewire/volt/zipball/48cff133990c6261c63ee279fc091af6f6c6654e", + "reference": "48cff133990c6261c63ee279fc091af6f6c6654e", "shasum": "" }, "require": { "laravel/framework": "^10.38.2|^11.0|^12.0", - "livewire/livewire": "^3.6.1", + "livewire/livewire": "^3.6.1|^4.0", "php": "^8.1" }, "require-dev": { "laravel/folio": "^1.1", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.15.0|^9.0|^10.0", - "pestphp/pest": "^2.9.5|^3.0", + "orchestra/testbench": "^8.36|^9.15|^10.8", + "pestphp/pest": "^2.9.5|^3.0|^4.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -2502,20 +3121,98 @@ "issues": "https://github.com/livewire/volt/issues", "source": "https://github.com/livewire/volt" }, - "time": "2025-03-05T15:20:55+00:00" + "time": "2025-11-25T16:19:15+00:00" }, { - "name": "monolog/monolog", - "version": "3.8.1", + "name": "maennchen/zipstream-php", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/Seldaek/monolog.git", - "reference": "aef6ee73a77a66e404dd6540934a9ef1b3c855b4" + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/aef6ee73a77a66e404dd6540934a9ef1b3c855b4", - "reference": "aef6ee73a77a66e404dd6540934a9ef1b3c855b4", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan MΓ€nnchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "AndrΓ‘s KolesΓ‘r", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-12-10T09:58:31+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -2533,7 +3230,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -2593,7 +3290,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.8.1" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -2605,20 +3302,86 @@ "type": "tidelift" } ], - "time": "2024-12-05T17:15:07+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { - "name": "nesbot/carbon", - "version": "3.8.6", + "name": "mtdowling/jmespath.php", + "version": "2.8.0", "source": { "type": "git", - "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd" + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ff2f20cf83bd4d503720632ce8a426dc747bf7fd", - "reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + }, + "time": "2024-09-04T18:46:31+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "bdb375400dcd162624531666db4799b36b64e4a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", + "reference": "bdb375400dcd162624531666db4799b36b64e4a1", "shasum": "" }, "require": { @@ -2626,9 +3389,9 @@ "ext-json": "*", "php": "^8.1", "psr/clock": "^1.0", - "symfony/clock": "^6.3 || ^7.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -2636,14 +3399,13 @@ "require-dev": { "doctrine/dbal": "^3.6.3 || ^4.0", "doctrine/orm": "^2.15.2 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.57.2", + "friendsofphp/php-cs-fixer": "^v3.87.1", "kylekatarnls/multi-tester": "^2.5.3", - "ondrejmirtes/better-reflection": "^6.25.0.4", "phpmd/phpmd": "^2.15.0", - "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan": "^1.11.2", - "phpunit/phpunit": "^10.5.20", - "squizlabs/php_codesniffer": "^3.9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4" }, "bin": [ "bin/carbon" @@ -2711,29 +3473,29 @@ "type": "tidelift" } ], - "time": "2025-02-20T17:33:38+00:00" + "time": "2025-12-02T21:04:28+00:00" }, { "name": "nette/schema", - "version": "v1.3.2", + "version": "v1.3.3", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "da801d52f0354f70a638673c4a0f04e16529431d" + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", - "reference": "da801d52f0354f70a638673c4a0f04e16529431d", + "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", "shasum": "" }, "require": { "nette/utils": "^4.0", - "php": "8.1 - 8.4" + "php": "8.1 - 8.5" }, "require-dev": { "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^1.0", + "phpstan/phpstan-nette": "^2.0@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -2743,6 +3505,9 @@ } }, "autoload": { + "psr-4": { + "Nette\\": "src" + }, "classmap": [ "src/" ] @@ -2771,35 +3536,35 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.2" + "source": "https://github.com/nette/schema/tree/v1.3.3" }, - "time": "2024-10-06T23:10:23+00:00" + "time": "2025-10-30T22:57:59+00:00" }, { "name": "nette/utils", - "version": "v4.0.5", + "version": "v4.1.1", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96" + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/736c567e257dbe0fcf6ce81b4d6dbe05c6899f96", - "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96", + "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", "shasum": "" }, "require": { - "php": "8.0 - 8.4" + "php": "8.2 - 8.5" }, "conflict": { "nette/finder": "<3", "nette/schema": "<1.2.2" }, "require-dev": { - "jetbrains/phpstorm-attributes": "dev-master", + "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan": "^1.0", + "phpstan/phpstan-nette": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -2813,10 +3578,13 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "4.1-dev" } }, "autoload": { + "psr-4": { + "Nette\\": "src" + }, "classmap": [ "src/" ] @@ -2857,22 +3625,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.5" + "source": "https://github.com/nette/utils/tree/v4.1.1" }, - "time": "2024-08-07T15:39:19+00:00" + "time": "2025-12-22T12:14:32+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -2891,7 +3659,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -2915,37 +3683,37 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.0", + "version": "v2.3.3", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda" + "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/52915afe6a1044e8b9cee1bcff836fb63acf9cda", - "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.1.8" + "symfony/console": "^7.3.6" }, "require-dev": { - "illuminate/console": "^11.33.2", - "laravel/pint": "^1.18.2", + "illuminate/console": "^11.46.1", + "laravel/pint": "^1.25.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0", - "phpstan/phpstan": "^1.12.11", - "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^7.1.8", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -2988,7 +3756,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.0" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" }, "funding": [ { @@ -3004,20 +3772,190 @@ "type": "github" } ], - "time": "2024-11-21T10:39:51+00:00" + "time": "2025-11-20T02:34:59+00:00" }, { - "name": "phpoption/phpoption", - "version": "1.9.3", + "name": "om/icalparser", + "version": "v3.2.1", "source": { "type": "git", - "url": "https://github.com/schmittjoh/php-option.git", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54" + "url": "https://github.com/OzzyCzech/icalparser.git", + "reference": "bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54", + "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" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "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" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "shasum": "" }, "require": { @@ -3025,7 +3963,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" }, "type": "library", "extra": { @@ -3067,7 +4005,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.3" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" }, "funding": [ { @@ -3079,7 +4017,117 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:41:07+00:00" + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.48", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-JΓΌrgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2025-12-15T11:51:42+00:00" }, { "name": "psr/clock", @@ -3495,16 +4543,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.7", + "version": "v0.12.18", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "d73fa3c74918ef4522bb8a3bf9cab39161c4b57c" + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/d73fa3c74918ef4522bb8a3bf9cab39161c4b57c", - "reference": "d73fa3c74918ef4522bb8a3bf9cab39161c4b57c", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", "shasum": "" }, "require": { @@ -3512,18 +4560,19 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.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" + "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" }, "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" + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" }, "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": [ @@ -3554,12 +4603,11 @@ "authors": [ { "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" + "email": "justin@justinhileman.info" } ], "description": "An interactive shell for modern PHP.", - "homepage": "http://psysh.org", + "homepage": "https://psysh.org", "keywords": [ "REPL", "console", @@ -3568,9 +4616,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.7" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" }, - "time": "2024-12-10T01:58:33+00:00" + "time": "2025-12-17T14:35:46+00:00" }, { "name": "ralouphie/getallheaders", @@ -3618,16 +4666,16 @@ }, { "name": "ramsey/collection", - "version": "2.1.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/ramsey/collection.git", - "reference": "3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109" + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/collection/zipball/3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109", - "reference": "3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", "shasum": "" }, "require": { @@ -3688,27 +4736,26 @@ ], "support": { "issues": "https://github.com/ramsey/collection/issues", - "source": "https://github.com/ramsey/collection/tree/2.1.0" + "source": "https://github.com/ramsey/collection/tree/2.1.1" }, - "time": "2025-03-02T04:48:29+00:00" + "time": "2025-03-22T05:38:12+00:00" }, { "name": "ramsey/uuid", - "version": "4.7.6", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "91039bc1faa45ba123c4328958e620d382ec7088" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088", - "reference": "91039bc1faa45ba123c4328958e620d382ec7088", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12", - "ext-json": "*", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -3716,26 +4763,23 @@ "rhumsaa/uuid": "self.version" }, "require-dev": { - "captainhook/captainhook": "^5.10", + "captainhook/captainhook": "^5.25", "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", - "doctrine/annotations": "^1.8", - "ergebnis/composer-normalize": "^2.15", - "mockery/mockery": "^1.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.2", - "php-mock/php-mock-mockery": "^1.3", - "php-parallel-lint/php-parallel-lint": "^1.1", - "phpbench/phpbench": "^1.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^8.5 || ^9", - "ramsey/composer-repl": "^1.4", - "slevomat/coding-standard": "^8.4", - "squizlabs/php_codesniffer": "^3.5", - "vimeo/psalm": "^4.9" + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" }, "suggest": { "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", @@ -3770,32 +4814,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.7.6" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "funding": [ - { - "url": "https://github.com/ramsey", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid", - "type": "tidelift" - } - ], - "time": "2024-04-27T21:32:50+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "spatie/browsershot", - "version": "5.0.8", + "version": "5.2.0", "source": { "type": "git", "url": "https://github.com/spatie/browsershot.git", - "reference": "0102971ae974022ec4a7a149e8924ea355b52cc3" + "reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/browsershot/zipball/0102971ae974022ec4a7a149e8924ea355b52cc3", - "reference": "0102971ae974022ec4a7a149e8924ea355b52cc3", + "url": "https://api.github.com/repos/spatie/browsershot/zipball/9bc6b8d67175810d7a399b2588c3401efe2d02a8", + "reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8", "shasum": "" }, "require": { @@ -3803,13 +4837,13 @@ "ext-json": "*", "php": "^8.2", "spatie/temporary-directory": "^2.0", - "symfony/process": "^6.0|^7.0" + "symfony/process": "^6.0|^7.0|^8.0" }, "require-dev": { - "pestphp/pest": "^3.0", + "pestphp/pest": "^3.0|^4.0", "spatie/image": "^3.6", "spatie/pdf-to-text": "^1.52", - "spatie/phpunit-snapshot-assertions": "^4.2.3|^5.0" + "spatie/phpunit-snapshot-assertions": "^5.0" }, "type": "library", "autoload": { @@ -3842,7 +4876,7 @@ "webpage" ], "support": { - "source": "https://github.com/spatie/browsershot/tree/5.0.8" + "source": "https://github.com/spatie/browsershot/tree/5.2.0" }, "funding": [ { @@ -3850,20 +4884,20 @@ "type": "github" } ], - "time": "2025-02-17T09:56:12+00:00" + "time": "2025-12-22T10:02:16+00:00" }, { "name": "spatie/laravel-package-tools", - "version": "1.19.0", + "version": "1.92.7", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa" + "reference": "f09a799850b1ed765103a4f0b4355006360c49a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa", - "reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5", + "reference": "f09a799850b1ed765103a4f0b4355006360c49a5", "shasum": "" }, "require": { @@ -3874,6 +4908,7 @@ "mockery/mockery": "^1.5", "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", "pestphp/pest": "^1.23|^2.1|^3.1", + "phpunit/php-code-coverage": "^9.0|^10.0|^11.0", "phpunit/phpunit": "^9.5.24|^10.5|^11.5", "spatie/pest-plugin-test-time": "^1.1|^2.2" }, @@ -3902,7 +4937,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.19.0" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7" }, "funding": [ { @@ -3910,69 +4945,7 @@ "type": "github" } ], - "time": "2025-02-06T14:58:20+00:00" - }, - { - "name": "spatie/pest-expectations", - "version": "1.3.0", - "source": { - "type": "git", - "url": "https://github.com/spatie/pest-expectations.git", - "reference": "e7e7be733f315157da97a44988099374edeffc23" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/spatie/pest-expectations/zipball/e7e7be733f315157da97a44988099374edeffc23", - "reference": "e7e7be733f315157da97a44988099374edeffc23", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "illuminate/contracts": "^9.47|^10.0", - "laravel/pint": "^1.2", - "pestphp/pest": "^1.20|^2.0", - "spatie/ray": "^1.28" - }, - "type": "library", - "autoload": { - "files": [ - "src/PestExpectations.php", - "src/Helpers.php" - ], - "psr-4": { - "Spatie\\PestExpectations\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Freek Van der Herten", - "email": "freek@spatie.be", - "role": "Developer" - } - ], - "description": "A collection of handy custom Pest customisations", - "homepage": "https://github.com/spatie/pest-expectations", - "keywords": [ - "pest-expectations", - "spatie" - ], - "support": { - "issues": "https://github.com/spatie/pest-expectations/issues", - "source": "https://github.com/spatie/pest-expectations/tree/1.3.0" - }, - "funding": [ - { - "url": "https://github.com/spatie", - "type": "github" - } - ], - "time": "2023-04-05T17:19:05+00:00" + "time": "2025-07-17T15:46:43+00:00" }, { "name": "spatie/temporary-directory", @@ -4036,23 +5009,88 @@ "time": "2025-01-13T13:04:43+00:00" }, { - "name": "symfony/clock", - "version": "v7.2.0", + "name": "stevebauman/purify", + "version": "v6.3.1", "source": { "type": "git", - "url": "https://github.com/symfony/clock.git", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + "url": "https://github.com/stevebauman/purify.git", + "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "url": "https://api.github.com/repos/stevebauman/purify/zipball/3acb5e77904f420ce8aad8fa1c7f394e82daa500", + "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/clock": "^1.0", - "symfony/polyfill-php83": "^1.28" + "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" + }, + { + "name": "symfony/clock", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/clock": "^1.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -4091,7 +5129,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.2.0" + "source": "https://github.com/symfony/clock/tree/v8.0.0" }, "funding": [ { @@ -4102,32 +5140,37 @@ "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": "2024-09-25T14:21:43+00:00" + "time": "2025-11-12T15:46:48+00:00" }, { "name": "symfony/console", - "version": "v7.2.1", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" + "symfony/string": "^7.2|^8.0" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -4141,16 +5184,16 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "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" + "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" }, "type": "library", "autoload": { @@ -4184,7 +5227,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.1" + "source": "https://github.com/symfony/console/tree/v7.4.3" }, "funding": [ { @@ -4195,29 +5238,33 @@ "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": "2024-12-11T03:49:26+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/css-selector", - "version": "v7.2.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -4249,7 +5296,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.2.0" + "source": "https://github.com/symfony/css-selector/tree/v8.0.0" }, "funding": [ { @@ -4260,25 +5307,29 @@ "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": "2024-09-25T14:21:43+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -4291,7 +5342,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -4316,7 +5367,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -4332,35 +5383,38 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/error-handler", - "version": "v7.2.4", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "aabf79938aa795350c07ce6464dd1985607d95d5" + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/aabf79938aa795350c07ce6464dd1985607d95d5", - "reference": "aabf79938aa795350c07ce6464dd1985607d95d5", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0" + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -4391,7 +5445,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.2.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.0" }, "funding": [ { @@ -4402,33 +5456,37 @@ "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-02-02T20:27:07+00:00" + "time": "2025-11-05T14:29:59+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.2.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" + "reference": "573f95783a2ec6e38752979db139f09fec033f03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/573f95783a2ec6e38752979db139f09fec033f03", + "reference": "573f95783a2ec6e38752979db139f09fec033f03", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<6.4", + "symfony/security-http": "<7.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -4437,13 +5495,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "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/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/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4471,7 +5530,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/v7.2.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.0" }, "funding": [ { @@ -4482,25 +5541,29 @@ "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": "2024-09-25T14:21:43+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", "shasum": "" }, "require": { @@ -4514,7 +5577,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -4547,7 +5610,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" }, "funding": [ { @@ -4563,27 +5626,97 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { - "name": "symfony/finder", - "version": "v7.2.2", + "name": "symfony/filesystem", + "version": "v8.0.1", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb" + "url": "https://github.com/symfony/filesystem.git", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb", + "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", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "fffe05569336549b20a1be64250b40516d6e8d06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", + "reference": "fffe05569336549b20a1be64250b40516d6e8d06", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4611,7 +5744,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.2" + "source": "https://github.com/symfony/finder/tree/v7.4.3" }, "funding": [ { @@ -4622,32 +5755,35 @@ "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": "2024-12-30T19:00:17+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.2.3", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "ee1b504b8926198be89d05e5b6fc4c3810c090f0" + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ee1b504b8926198be89d05e5b6fc4c3810c090f0", - "reference": "ee1b504b8926198be89d05e5b6fc4c3810c090f0", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { "doctrine/dbal": "<3.6", @@ -4656,12 +5792,13 @@ "require-dev": { "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "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" + "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" }, "type": "library", "autoload": { @@ -4689,7 +5826,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.2.3" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.3" }, "funding": [ { @@ -4700,34 +5837,38 @@ "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-01-17T10:56:55+00:00" + "time": "2025-12-23T14:23:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.2.4", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "9f1103734c5789798fefb90e91de4586039003ed" + "reference": "885211d4bed3f857b8c964011923528a55702aa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9f1103734c5789798fefb90e91de4586039003ed", - "reference": "9f1103734c5789798fefb90e91de4586039003ed", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", + "reference": "885211d4bed3f857b8c964011923528a55702aa5", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -4737,6 +5878,7 @@ "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", @@ -4754,27 +5896,27 @@ }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.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/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/http-client-contracts": "^2.5|^3", - "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/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/translation-contracts": "^2.5|^3", - "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", + "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", "twig/twig": "^3.12" }, "type": "library", @@ -4803,7 +5945,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.2.4" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.3" }, "funding": [ { @@ -4814,25 +5956,29 @@ "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-02-26T11:01:22+00:00" + "time": "2025-12-31T08:43:57+00:00" }, { "name": "symfony/mailer", - "version": "v7.2.3", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "f3871b182c44997cf039f3b462af4a48fb85f9d3" + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/f3871b182c44997cf039f3b462af4a48fb85f9d3", - "reference": "f3871b182c44997cf039f3b462af4a48fb85f9d3", + "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", "shasum": "" }, "require": { @@ -4840,8 +5986,8 @@ "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/mime": "^7.2", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -4852,10 +5998,10 @@ "symfony/twig-bridge": "<6.4" }, "require-dev": { - "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" + "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" }, "type": "library", "autoload": { @@ -4883,7 +6029,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.2.3" + "source": "https://github.com/symfony/mailer/tree/v7.4.3" }, "funding": [ { @@ -4894,29 +6040,34 @@ "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-01-27T11:08:17+00:00" + "time": "2025-12-16T08:02:06+00:00" }, { "name": "symfony/mime", - "version": "v7.2.4", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "87ca22046b78c3feaff04b337f33b38510fd686b" + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/87ca22046b78c3feaff04b337f33b38510fd686b", - "reference": "87ca22046b78c3feaff04b337f33b38510fd686b", + "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -4931,11 +6082,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", - "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" + "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" }, "type": "library", "autoload": { @@ -4967,7 +6118,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.2.4" + "source": "https://github.com/symfony/mime/tree/v7.4.0" }, "funding": [ { @@ -4978,16 +6129,20 @@ "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-02-19T08:51:20+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -5046,7 +6201,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -5057,6 +6212,10 @@ "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" @@ -5066,16 +6225,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", "shasum": "" }, "require": { @@ -5124,7 +6283,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" }, "funding": [ { @@ -5135,25 +6294,29 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2025-06-27T09:58:17+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773" + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", "shasum": "" }, "require": { @@ -5207,7 +6370,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" }, "funding": [ { @@ -5218,16 +6381,20 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2024-09-10T14:38:51+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -5288,7 +6455,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" }, "funding": [ { @@ -5299,6 +6466,10 @@ "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" @@ -5308,19 +6479,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -5368,7 +6540,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -5379,25 +6551,29 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { @@ -5448,7 +6624,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -5459,25 +6635,29 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", "shasum": "" }, "require": { @@ -5524,7 +6704,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" }, "funding": [ { @@ -5535,16 +6715,180 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "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-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "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-06-23T16:12:55+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -5603,7 +6947,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" }, "funding": [ { @@ -5614,6 +6958,10 @@ "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" @@ -5623,16 +6971,16 @@ }, { "name": "symfony/process", - "version": "v7.2.4", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf" + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf", - "reference": "d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf", + "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", "shasum": "" }, "require": { @@ -5664,7 +7012,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.4" + "source": "https://github.com/symfony/process/tree/v7.4.3" }, "funding": [ { @@ -5675,25 +7023,29 @@ "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-02-05T08:33:46+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/routing", - "version": "v7.2.3", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996" + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/ee9a67edc6baa33e5fae662f94f91fd262930996", - "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996", + "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", "shasum": "" }, "require": { @@ -5707,11 +7059,11 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "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" + "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" }, "type": "library", "autoload": { @@ -5745,7 +7097,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.2.3" + "source": "https://github.com/symfony/routing/tree/v7.4.3" }, "funding": [ { @@ -5756,25 +7108,29 @@ "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-01-17T10:56:55+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -5792,7 +7148,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -5828,7 +7184,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -5839,44 +7195,47 @@ "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": "2024-09-25T14:20:29+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", "shasum": "" }, "require": { - "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" + "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" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "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/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5915,7 +7274,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v8.0.1" }, "funding": [ { @@ -5926,60 +7285,58 @@ "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": "2024-11-13T13:31:26+00:00" + "time": "2025-12-01T09:13:36+00:00" }, { "name": "symfony/translation", - "version": "v7.2.4", + "version": "v8.0.3", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "283856e6981286cc0d800b53bd5703e8e363f05a" + "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/283856e6981286cc0d800b53bd5703e8e363f05a", - "reference": "283856e6981286cc0d800b53bd5703e8e363f05a", + "url": "https://api.github.com/repos/symfony/translation/zipball/60a8f11f0e15c48f2cc47c4da53873bb5b62135d", + "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" }, "conflict": { - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", + "nikic/php-parser": "<5.0", "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<6.4", - "symfony/yaml": "<6.4" + "symfony/service-contracts": "<2.5" }, "provide": { "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { - "nikic/php-parser": "^4.18|^5.0", + "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "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/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/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^6.4|^7.0", + "symfony/routing": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0" + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6010,7 +7367,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.2.4" + "source": "https://github.com/symfony/translation/tree/v8.0.3" }, "funding": [ { @@ -6021,25 +7378,29 @@ "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-02-13T10:27:23+00:00" + "time": "2025-12-21T10:59:45+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.5.1", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "4667ff3bd513750603a09c8dedbea942487fb07c" + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c", - "reference": "4667ff3bd513750603a09c8dedbea942487fb07c", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", "shasum": "" }, "require": { @@ -6052,7 +7413,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -6088,7 +7449,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" }, "funding": [ { @@ -6099,25 +7460,29 @@ "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": "2024-09-25T14:20:29+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/uid", - "version": "v7.2.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2d294d0c48df244c71c105a169d0190bfb080426" + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2d294d0c48df244c71c105a169d0190bfb080426", - "reference": "2d294d0c48df244c71c105a169d0190bfb080426", + "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", "shasum": "" }, "require": { @@ -6125,7 +7490,7 @@ "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6162,7 +7527,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.2.0" + "source": "https://github.com/symfony/uid/tree/v7.4.0" }, "funding": [ { @@ -6173,40 +7538,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": "2024-09-25T14:21:43+00:00" + "time": "2025-09-25T11:02:55+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.2.3", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "82b478c69745d8878eb60f9a049a4d584996f73a" + "reference": "7e99bebcb3f90d8721890f2963463280848cba92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/82b478c69745d8878eb60f9a049a4d584996f73a", - "reference": "82b478c69745d8878eb60f9a049a4d584996f73a", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", + "reference": "7e99bebcb3f90d8721890f2963463280848cba92", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", - "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", + "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", "twig/twig": "^3.12" }, "bin": [ @@ -6245,7 +7614,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.2.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" }, "funding": [ { @@ -6256,32 +7625,193 @@ "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-01-17T11:39:41+00:00" + "time": "2025-12-18T07:04:31+00:00" }, { - "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.3.0", + "name": "symfony/var-exporter", + "version": "v7.4.0", "source": { "type": "git", - "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "url": "https://github.com/symfony/var-exporter.git", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "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" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" + }, + "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-09-11T10:15:23+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "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": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.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-04T18:11:45+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -6314,32 +7844,32 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.6.1", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2" + "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a59a13791077fe3d44f90e7133eb68e7d22eaff2", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.1.4", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -6388,7 +7918,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.1" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" }, "funding": [ { @@ -6400,7 +7930,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:52:34+00:00" + "time": "2025-12-27T19:49:13+00:00" }, { "name": "voku/portable-ascii", @@ -6477,120 +8007,56 @@ "time": "2024-11-21T01:49:47+00:00" }, { - "name": "voku/simple_html_dom", - "version": "4.8.10", + "name": "wnx/sidecar-browsershot", + "version": "v2.7.0", "source": { "type": "git", - "url": "https://github.com/voku/simple_html_dom.git", - "reference": "716822ed52ed3a1881542be07a786270de390e99" + "url": "https://github.com/stefanzweifel/sidecar-browsershot.git", + "reference": "e42a996c6fab4357919cd5e3f3fab33f019cdd80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/simple_html_dom/zipball/716822ed52ed3a1881542be07a786270de390e99", - "reference": "716822ed52ed3a1881542be07a786270de390e99", + "url": "https://api.github.com/repos/stefanzweifel/sidecar-browsershot/zipball/e42a996c6fab4357919cd5e3f3fab33f019cdd80", + "reference": "e42a996c6fab4357919cd5e3f3fab33f019cdd80", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-simplexml": "*", - "php": ">=7.0.0", - "symfony/css-selector": "~3.0 || ~4.0 || ~5.0 || ~6.0 || ~7.0" + "hammerstone/sidecar": "^0.7", + "illuminate/contracts": "^12.0", + "php": "^8.4", + "spatie/browsershot": "^4.0 || ^5.0", + "spatie/laravel-package-tools": "^1.9.2" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" - }, - "suggest": { - "voku/portable-utf8": "If you need e.g. UTF-8 fixed output." - }, - "type": "library", - "autoload": { - "psr-4": { - "voku\\helper\\": "src/voku/helper/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "dimabdc", - "email": "support@titor.ru", - "homepage": "https://github.com/dimabdc", - "role": "Developer" - }, - { - "name": "Lars Moelleken", - "homepage": "https://www.moelleken.org/", - "role": "Fork-Maintainer" - } - ], - "description": "Simple HTML DOM package.", - "homepage": "https://github.com/voku/simple_html_dom", - "keywords": [ - "HTML Parser", - "dom", - "php dom" - ], - "support": { - "issues": "https://github.com/voku/simple_html_dom/issues", - "source": "https://github.com/voku/simple_html_dom/tree/4.8.10" - }, - "funding": [ - { - "url": "https://www.paypal.me/moelleken", - "type": "custom" - }, - { - "url": "https://github.com/voku", - "type": "github" - }, - { - "url": "https://www.patreon.com/voku", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/voku/simple_html_dom", - "type": "tidelift" - } - ], - "time": "2024-07-03T16:05:14+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.11.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", - "shasum": "" - }, - "require": { - "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" + "ext-imagick": "*", + "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", + "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", + "spatie/image": "^3.3", + "spatie/pixelmatch-php": "^1.0" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "1.10-dev" + "laravel": { + "aliases": { + "SidecarBrowsershot": "Wnx\\SidecarBrowsershot\\Facades\\SidecarBrowsershot" + }, + "providers": [ + "Wnx\\SidecarBrowsershot\\SidecarBrowsershotServiceProvider" + ] } }, "autoload": { "psr-4": { - "Webmozart\\Assert\\": "src/" + "Wnx\\SidecarBrowsershot\\": "src", + "Wnx\\SidecarBrowsershot\\Database\\Factories\\": "database/factories" } }, "notification-url": "https://packagist.org/downloads/", @@ -6599,36 +8065,47 @@ ], "authors": [ { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" + "name": "Stefan Zweifel", + "email": "stefan@stefanzweifel.dev", + "role": "Developer" } ], - "description": "Assertions to validate method input/output with nice error messages.", + "description": "A Sidecar function to run Browsershot on Lambda.", + "homepage": "https://github.com/stefanzweifel/sidecar-browsershot", "keywords": [ - "assert", - "check", - "validate" + "browsershot", + "lambda", + "laravel", + "sidecar", + "sidecar-browsershot", + "wnx" ], "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" + "issues": "https://github.com/stefanzweifel/sidecar-browsershot/issues", + "source": "https://github.com/stefanzweifel/sidecar-browsershot/tree/v2.7.0" }, - "time": "2022-06-03T18:03:27+00:00" + "funding": [ + { + "url": "https://github.com/stefanzweifel", + "type": "github" + } + ], + "time": "2025-11-22T08:49:08+00:00" } ], "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.7.0", + "version": "v7.16.1", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "4fb3f73bc5a4c3146bac2850af7dc72435a32daf" + "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/4fb3f73bc5a4c3146bac2850af7dc72435a32daf", - "reference": "4fb3f73bc5a4c3146bac2850af7dc72435a32daf", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", "shasum": "" }, "require": { @@ -6636,27 +8113,27 @@ "ext-pcre": "*", "ext-reflection": "*", "ext-simplexml": "*", - "fidry/cpu-core-counter": "^1.2.0", - "jean85/pretty-package-versions": "^2.1.0", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0", - "phpunit/php-code-coverage": "^11.0.8", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-timer": "^7.0.1", - "phpunit/phpunit": "^11.5.1", - "sebastian/environment": "^7.2.0", - "symfony/console": "^6.4.14 || ^7.2.1", - "symfony/process": "^6.4.14 || ^7.2.0" + "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", + "phpunit/php-file-iterator": "^6", + "phpunit/php-timer": "^8", + "phpunit/phpunit": "^12.5.4", + "sebastian/environment": "^8.0.3", + "symfony/console": "^7.3.4 || ^8.0.0", + "symfony/process": "^7.3.4 || ^8.0.0" }, "require-dev": { - "doctrine/coding-standard": "^12.0.0", + "doctrine/coding-standard": "^14.0.0", + "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.0.3", - "phpstan/phpstan-deprecation-rules": "^2.0.1", - "phpstan/phpstan-phpunit": "^2.0.1", - "phpstan/phpstan-strict-rules": "^2", - "squizlabs/php_codesniffer": "^3.11.1", - "symfony/filesystem": "^6.4.13 || ^7.2.0" + "phpstan/phpstan": "^2.1.33", + "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" }, "bin": [ "bin/paratest", @@ -6696,7 +8173,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.7.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" }, "funding": [ { @@ -6708,30 +8185,33 @@ "type": "paypal" } ], - "time": "2024-12-11T14:50:44+00:00" + "time": "2026-01-08T07:23:06+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.4", + "version": "1.1.5", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -6751,9 +8231,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" }, - "time": "2024-12-07T21:18:45+00:00" + "time": "2025-04-07T20:06:18+00:00" }, { "name": "fakerphp/faker", @@ -6820,16 +8300,16 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "8520451a140d3f46ac33042715115e290cf5785f" + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", - "reference": "8520451a140d3f46ac33042715115e290cf5785f", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { @@ -6839,10 +8319,10 @@ "fidry/makefile": "^0.2.0", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^1.9.2", - "phpstan/phpstan-deprecation-rules": "^1.0.0", - "phpstan/phpstan-phpunit": "^1.2.2", - "phpstan/phpstan-strict-rules": "^1.4.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^8.5.31 || ^9.5.26", "webmozarts/strict-phpunit": "^7.5" }, @@ -6869,7 +8349,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" }, "funding": [ { @@ -6877,20 +8357,20 @@ "type": "github" } ], - "time": "2024-08-06T10:04:20+00:00" + "time": "2025-08-14T07:29:31+00:00" }, { "name": "filp/whoops", - "version": "2.17.0", + "version": "2.18.4", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "075bc0c26631110584175de6523ab3f1652eb28e" + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/075bc0c26631110584175de6523ab3f1652eb28e", - "reference": "075bc0c26631110584175de6523ab3f1652eb28e", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", "shasum": "" }, "require": { @@ -6940,7 +8420,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.17.0" + "source": "https://github.com/filp/whoops/tree/2.18.4" }, "funding": [ { @@ -6948,24 +8428,24 @@ "type": "github" } ], - "time": "2025-01-25T12:00:00+00:00" + "time": "2025-08-08T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", - "version": "v2.0.1", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3" + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", "shasum": "" }, "require": { - "php": "^5.3|^7.0|^8.0" + "php": "^7.4|^8.0" }, "replace": { "cordoval/hamcrest-php": "*", @@ -6973,8 +8453,8 @@ "kodova/hamcrest-php": "*" }, "require-dev": { - "phpunit/php-file-iterator": "^1.4 || ^2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0" + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { @@ -6997,22 +8477,63 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" }, - "time": "2020-07-09T08:09:16+00:00" + "time": "2025-04-30T06:54:44+00:00" }, { - "name": "jean85/pretty-package-versions", - "version": "2.1.0", + "name": "iamcal/sql-parser", + "version": "v0.6", "source": { "type": "git", - "url": "https://github.com/Jean85/pretty-package-versions.git", - "reference": "3c4e5f62ba8d7de1734312e4fff32f67a8daaf10" + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/3c4e5f62ba8d7de1734312e4fff32f67a8daaf10", - "reference": "3c4e5f62ba8d7de1734312e4fff32f67a8daaf10", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.6" + }, + "time": "2025-03-17T16:59:46+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", "shasum": "" }, "require": { @@ -7022,8 +8543,9 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "jean85/composer-provided-replaced-stub-package": "^1.0", - "phpstan/phpstan": "^1.4", + "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", "vimeo/psalm": "^4.3 || ^5.0" }, "type": "library", @@ -7056,22 +8578,251 @@ ], "support": { "issues": "https://github.com/Jean85/pretty-package-versions/issues", - "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.0" + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" }, - "time": "2024-11-18T16:19:46+00:00" + "time": "2025-03-19T14:43:43+00:00" }, { - "name": "laravel/pail", - "version": "v1.2.2", + "name": "larastan/larastan", + "version": "v3.8.1", "source": { "type": "git", - "url": "https://github.com/laravel/pail.git", - "reference": "f31f4980f52be17c4667f3eafe034e6826787db2" + "url": "https://github.com/larastan/larastan.git", + "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/f31f4980f52be17c4667f3eafe034e6826787db2", - "reference": "f31f4980f52be17c4667f3eafe034e6826787db2", + "url": "https://api.github.com/repos/larastan/larastan/zipball/ff3725291bc4c7e6032b5a54776e3e5104c86db9", + "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.6.0", + "illuminate/console": "^11.44.2 || ^12.4.1", + "illuminate/container": "^11.44.2 || ^12.4.1", + "illuminate/contracts": "^11.44.2 || ^12.4.1", + "illuminate/database": "^11.44.2 || ^12.4.1", + "illuminate/http": "^11.44.2 || ^12.4.1", + "illuminate/pipeline": "^11.44.2 || ^12.4.1", + "illuminate/support": "^11.44.2 || ^12.4.1", + "php": "^8.2", + "phpstan/phpstan": "^2.1.32" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1", + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "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" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2025-12-11T16:37:35+00:00" + }, + { + "name": "laravel/boost", + "version": "v1.8.9", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/1f2c2d41b5216618170fb6730ec13bf894c5bffd", + "reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd", + "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" + }, + "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" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "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.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2026-01-07T18:43:11+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.5.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/b9bdd8d6f8b547c8733fe6826b1819341597ba3c", + "reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c", + "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" + }, + "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" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-12-19T19:32:34+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", "shasum": "" }, "require": { @@ -7088,10 +8839,10 @@ "require-dev": { "laravel/framework": "^10.24|^11.0|^12.0", "laravel/pint": "^1.13", - "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.10", + "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", + "phpstan/phpstan": "^1.12.27", "symfony/var-dumper": "^6.3|^7.0" }, "type": "library", @@ -7127,6 +8878,7 @@ "description": "Easily delve into your Laravel application's log files directly from the command line.", "homepage": "https://github.com/laravel/pail", "keywords": [ + "dev", "laravel", "logs", "php", @@ -7136,20 +8888,20 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-01-28T15:15:15+00:00" + "time": "2025-11-20T16:29:35+00:00" }, { "name": "laravel/pint", - "version": "v1.21.0", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "531fa0871fbde719c51b12afa3a443b8f4e4b425" + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/531fa0871fbde719c51b12afa3a443b8f4e4b425", - "reference": "531fa0871fbde719c51b12afa3a443b8f4e4b425", + "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", "shasum": "" }, "require": { @@ -7160,13 +8912,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.68.5", - "illuminate/view": "^11.42.0", - "larastan/larastan": "^3.0.4", - "laravel-zero/framework": "^11.36.1", + "friendsofphp/php-cs-fixer": "^3.92.4", + "illuminate/view": "^12.44.0", + "larastan/larastan": "^3.8.1", + "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3", - "pestphp/pest": "^2.36.0" + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" }, "bin": [ "builds/pint" @@ -7192,6 +8944,7 @@ "description": "An opinionated code formatter for PHP.", "homepage": "https://laravel.com", "keywords": [ + "dev", "format", "formatter", "lint", @@ -7202,20 +8955,81 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-02-18T03:18:57+00:00" + "time": "2026-01-05T16:49:17+00:00" }, { - "name": "laravel/sail", - "version": "v1.41.0", + "name": "laravel/roster", + "version": "v0.2.9", "source": { "type": "git", - "url": "https://github.com/laravel/sail.git", - "reference": "fe1a4ada0abb5e4bd99eb4e4b0d87906c00cdeec" + "url": "https://github.com/laravel/roster.git", + "reference": "82bbd0e2de614906811aebdf16b4305956816fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/fe1a4ada0abb5e4bd99eb4e4b0d87906c00cdeec", - "reference": "fe1a4ada0abb5e4bd99eb4e4b0d87906c00cdeec", + "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6", + "reference": "82bbd0e2de614906811aebdf16b4305956816fa6", + "shasum": "" + }, + "require": { + "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", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "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": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-10-20T09:56:46+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.52.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", "shasum": "" }, "require": { @@ -7228,7 +9042,7 @@ }, "require-dev": { "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", - "phpstan/phpstan": "^1.10" + "phpstan/phpstan": "^2.0" }, "bin": [ "bin/sail" @@ -7265,7 +9079,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-01-24T15:45:36+00:00" + "time": "2026-01-01T02:46:03+00:00" }, { "name": "mockery/mockery", @@ -7352,16 +9166,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -7400,7 +9214,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -7408,42 +9222,43 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nunomaduro/collision", - "version": "v8.6.1", + "version": "v8.8.3", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "86f003c132143d5a2ab214e19933946409e0cae7" + "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/86f003c132143d5a2ab214e19933946409e0cae7", - "reference": "86f003c132143d5a2ab214e19933946409e0cae7", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", "shasum": "" }, "require": { - "filp/whoops": "^2.16.0", - "nunomaduro/termwind": "^2.3.0", + "filp/whoops": "^2.18.1", + "nunomaduro/termwind": "^2.3.1", "php": "^8.2.0", - "symfony/console": "^7.2.1" + "symfony/console": "^7.3.0" }, "conflict": { - "laravel/framework": "<11.39.1 || >=13.0.0", - "phpunit/phpunit": "<11.5.3 || >=12.0.0" + "laravel/framework": "<11.44.2 || >=13.0.0", + "phpunit/phpunit": "<11.5.15 || >=13.0.0" }, "require-dev": { - "larastan/larastan": "^2.9.12", - "laravel/framework": "^11.39.1", - "laravel/pint": "^1.20.0", - "laravel/sail": "^1.40.0", - "laravel/sanctum": "^4.0.7", - "laravel/tinker": "^2.10.0", - "orchestra/testbench-core": "^9.9.2", - "pestphp/pest": "^3.7.3", - "sebastian/environment": "^6.1.0 || ^7.2.0" + "brianium/paratest": "^7.8.3", + "larastan/larastan": "^3.4.2", + "laravel/framework": "^11.44.2 || ^12.18", + "laravel/pint": "^1.22.1", + "laravel/sail": "^1.43.1", + "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", + "sebastian/environment": "^7.2.1 || ^8.0" }, "type": "library", "extra": { @@ -7506,42 +9321,45 @@ "type": "patreon" } ], - "time": "2025-01-23T13:41:43+00:00" + "time": "2025-11-20T02:55:25+00:00" }, { "name": "pestphp/pest", - "version": "v3.7.4", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "4a987d3d5c4e3ba36c76fecbf56113baac2d1b2b" + "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/4a987d3d5c4e3ba36c76fecbf56113baac2d1b2b", - "reference": "4a987d3d5c4e3ba36c76fecbf56113baac2d1b2b", + "url": "https://api.github.com/repos/pestphp/pest/zipball/bc57a84e77afd4544ff9643a6858f68d05aeab96", + "reference": "bc57a84e77afd4544ff9643a6858f68d05aeab96", "shasum": "" }, "require": { - "brianium/paratest": "^7.7.0", - "nunomaduro/collision": "^8.6.1", - "nunomaduro/termwind": "^2.3.0", - "pestphp/pest-plugin": "^3.0.0", - "pestphp/pest-plugin-arch": "^3.0.0", - "pestphp/pest-plugin-mutate": "^3.0.5", - "php": "^8.2.0", - "phpunit/phpunit": "^11.5.3" + "brianium/paratest": "^7.16.0", + "nunomaduro/collision": "^8.8.3", + "nunomaduro/termwind": "^2.3.3", + "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", + "php": "^8.3.0", + "phpunit/phpunit": "^12.5.4", + "symfony/process": "^7.4.3|^8.0.0" }, "conflict": { - "filp/whoops": "<2.16.0", - "phpunit/phpunit": ">11.5.3", - "sebastian/exporter": "<6.0.0", + "filp/whoops": "<2.18.3", + "phpunit/phpunit": ">12.5.4", + "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "pestphp/pest-dev-tools": "^3.3.0", - "pestphp/pest-plugin-type-coverage": "^3.2.3", - "symfony/process": "^7.2.0" + "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" }, "bin": [ "bin/pest" @@ -7567,6 +9385,7 @@ "Pest\\Plugins\\Snapshot", "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", + "Pest\\Plugins\\Shard", "Pest\\Plugins\\Parallel" ] }, @@ -7606,7 +9425,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v3.7.4" + "source": "https://github.com/pestphp/pest/tree/v4.3.1" }, "funding": [ { @@ -7618,34 +9437,34 @@ "type": "github" } ], - "time": "2025-01-23T14:03:29+00:00" + "time": "2026-01-04T16:29:59+00:00" }, { "name": "pestphp/pest-plugin", - "version": "v3.0.0", + "version": "v4.0.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin.git", - "reference": "e79b26c65bc11c41093b10150c1341cc5cdbea83" + "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/e79b26c65bc11c41093b10150c1341cc5cdbea83", - "reference": "e79b26c65bc11c41093b10150c1341cc5cdbea83", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/9d4b93d7f73d3f9c3189bb22c220fef271cdf568", + "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568", "shasum": "" }, "require": { "composer-plugin-api": "^2.0.0", "composer-runtime-api": "^2.2.2", - "php": "^8.2" + "php": "^8.3" }, "conflict": { - "pestphp/pest": "<3.0.0" + "pestphp/pest": "<4.0.0" }, "require-dev": { - "composer/composer": "^2.7.9", - "pestphp/pest": "^3.0.0", - "pestphp/pest-dev-tools": "^3.0.0" + "composer/composer": "^2.8.10", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" }, "type": "composer-plugin", "extra": { @@ -7672,7 +9491,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin/tree/v3.0.0" + "source": "https://github.com/pestphp/pest-plugin/tree/v4.0.0" }, "funding": [ { @@ -7688,30 +9507,30 @@ "type": "patreon" } ], - "time": "2024-09-08T23:21:41+00:00" + "time": "2025-08-20T12:35:58+00:00" }, { "name": "pestphp/pest-plugin-arch", - "version": "v3.0.0", + "version": "v4.0.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-arch.git", - "reference": "0a27e55a270cfe73d8cb70551b91002ee2cb64b0" + "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/0a27e55a270cfe73d8cb70551b91002ee2cb64b0", - "reference": "0a27e55a270cfe73d8cb70551b91002ee2cb64b0", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d", "shasum": "" }, "require": { - "pestphp/pest-plugin": "^3.0.0", - "php": "^8.2", - "ta-tikoma/phpunit-architecture-test": "^0.8.4" + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "ta-tikoma/phpunit-architecture-test": "^0.8.5" }, "require-dev": { - "pestphp/pest": "^3.0.0", - "pestphp/pest-dev-tools": "^3.0.0" + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" }, "type": "library", "extra": { @@ -7746,7 +9565,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-arch/tree/v3.0.0" + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.0" }, "funding": [ { @@ -7758,30 +9577,30 @@ "type": "github" } ], - "time": "2024-09-08T23:23:55+00:00" + "time": "2025-08-20T13:10:51+00:00" }, { "name": "pestphp/pest-plugin-drift", - "version": "v3.0.0", + "version": "v4.0.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-drift.git", - "reference": "cd506d2b931eb1443b878229b472c59d6f2d8ee8" + "reference": "f4972f2dc6e6e6f1b47db0a8472ca70ca42b622e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-drift/zipball/cd506d2b931eb1443b878229b472c59d6f2d8ee8", - "reference": "cd506d2b931eb1443b878229b472c59d6f2d8ee8", + "url": "https://api.github.com/repos/pestphp/pest-plugin-drift/zipball/f4972f2dc6e6e6f1b47db0a8472ca70ca42b622e", + "reference": "f4972f2dc6e6e6f1b47db0a8472ca70ca42b622e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.1.0", - "pestphp/pest": "^3.0.0", - "php": "^8.2.0", - "symfony/finder": "^7.1.4" + "nikic/php-parser": "^5.6.1", + "pestphp/pest": "^4.0.0", + "php": "^8.3.0", + "symfony/finder": "^7.3.2" }, "require-dev": { - "pestphp/pest-dev-tools": "^3.0.0" + "pestphp/pest-dev-tools": "^4.0.0" }, "type": "library", "extra": { @@ -7811,7 +9630,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-drift/tree/v3.0.0" + "source": "https://github.com/pestphp/pest-plugin-drift/tree/v4.0.0" }, "funding": [ { @@ -7827,31 +9646,31 @@ "type": "github" } ], - "time": "2024-09-08T23:45:48+00:00" + "time": "2025-08-20T12:54:20+00:00" }, { "name": "pestphp/pest-plugin-laravel", - "version": "v3.1.0", + "version": "v4.0.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-laravel.git", - "reference": "1c4e994476375c72aa7aebaaa97aa98f5d5378cd" + "reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/1c4e994476375c72aa7aebaaa97aa98f5d5378cd", - "reference": "1c4e994476375c72aa7aebaaa97aa98f5d5378cd", + "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/e12a07046b826a40b1c8632fd7b80d6b8d7b628e", + "reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e", "shasum": "" }, "require": { - "laravel/framework": "^11.39.1|^12.0.0", - "pestphp/pest": "^3.7.4", - "php": "^8.2.0" + "laravel/framework": "^11.45.2|^12.25.0", + "pestphp/pest": "^4.0.0", + "php": "^8.3.0" }, "require-dev": { - "laravel/dusk": "^8.2.13|dev-develop", - "orchestra/testbench": "^9.9.0|^10.0.0", - "pestphp/pest-dev-tools": "^3.3.0" + "laravel/dusk": "^8.3.3", + "orchestra/testbench": "^9.13.0|^10.5.0", + "pestphp/pest-dev-tools": "^4.0.0" }, "type": "library", "extra": { @@ -7889,7 +9708,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v3.1.0" + "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.0.0" }, "funding": [ { @@ -7901,32 +9720,32 @@ "type": "github" } ], - "time": "2025-01-24T13:22:39+00:00" + "time": "2025-08-20T12:46:37+00:00" }, { "name": "pestphp/pest-plugin-mutate", - "version": "v3.0.5", + "version": "v4.0.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-mutate.git", - "reference": "e10dbdc98c9e2f3890095b4fe2144f63a5717e08" + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/e10dbdc98c9e2f3890095b4fe2144f63a5717e08", - "reference": "e10dbdc98c9e2f3890095b4fe2144f63a5717e08", + "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/d9b32b60b2385e1688a68cc227594738ec26d96c", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c", "shasum": "" }, "require": { - "nikic/php-parser": "^5.2.0", - "pestphp/pest-plugin": "^3.0.0", - "php": "^8.2", + "nikic/php-parser": "^5.6.1", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", "psr/simple-cache": "^3.0.0" }, "require-dev": { - "pestphp/pest": "^3.0.8", - "pestphp/pest-dev-tools": "^3.0.0", - "pestphp/pest-plugin-type-coverage": "^3.0.0" + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.0" }, "type": "library", "autoload": { @@ -7939,6 +9758,10 @@ "MIT" ], "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + }, { "name": "Sandro Gehri", "email": "sandrogehri@gmail.com" @@ -7957,7 +9780,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v3.0.5" + "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v4.0.1" }, "funding": [ { @@ -7973,7 +9796,63 @@ "type": "github" } ], - "time": "2024-09-22T07:54:40+00:00" + "time": "2025-08-21T20:19:25+00:00" + }, + { + "name": "pestphp/pest-plugin-profanity", + "version": "v4.2.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-profanity.git", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "shasum": "" + }, + "require": { + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3" + }, + "require-dev": { + "faissaloux/pest-plugin-inside": "^1.9", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Profanity\\Plugin" + ] + } + }, + "autoload": { + "psr-4": { + "Pest\\Profanity\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Profanity Plugin", + "keywords": [ + "framework", + "pest", + "php", + "plugin", + "profanity", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" + }, + "time": "2025-12-08T00:13:17+00:00" }, { "name": "phar-io/manifest", @@ -8148,16 +10027,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.1", + "version": "5.6.6", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", "shasum": "" }, "require": { @@ -8167,7 +10046,7 @@ "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -8206,22 +10085,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.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" }, - "time": "2024-12-07T09:39:29+00:00" + "time": "2025-12-22T21:13:58+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.10.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", "shasum": "" }, "require": { @@ -8264,22 +10143,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.10.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" }, - "time": "2024-11-09T15:12:26+00:00" + "time": "2025-11-21T15:09:14+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.1.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", "shasum": "" }, "require": { @@ -8311,41 +10190,93 @@ "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.1.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" }, - "time": "2025-02-19T13:28:12+00:00" + "time": "2025-08-30T15:50:23+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.33", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f", + "reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-12-05T10:24:31+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.9", + "version": "12.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7" + "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/14d63fbcca18457e49c6f8bebaa91a87e8e188d7", - "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", + "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", - "php": ">=8.2", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-text-template": "^4.0.1", - "sebastian/code-unit-reverse-lookup": "^4.0.1", - "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.0", - "sebastian/lines-of-code": "^3.0.1", - "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.2.3" + "nikic/php-parser": "^5.7.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/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.2" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -8354,7 +10285,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "11.0.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -8383,40 +10314,52 @@ "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/11.0.9" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" }, "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/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2025-02-25T13:26:39+00:00" + "time": "2025-12-24T07:03:04+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "961bc913d42fe24a257bfff826a5068079ac7782" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", + "reference": "961bc913d42fe24a257bfff826a5068079ac7782", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -8444,7 +10387,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0" }, "funding": [ { @@ -8452,28 +10395,28 @@ "type": "github" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2025-02-07T04:58:37+00:00" }, { "name": "phpunit/php-invoker", - "version": "5.0.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-pcntl": "*" @@ -8481,7 +10424,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -8508,7 +10451,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" }, "funding": [ { @@ -8516,32 +10459,32 @@ "type": "github" } ], - "time": "2024-07-03T05:07:44+00:00" + "time": "2025-02-07T04:58:58+00:00" }, { "name": "phpunit/php-text-template", - "version": "4.0.1", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -8568,7 +10511,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" }, "funding": [ { @@ -8576,32 +10519,32 @@ "type": "github" } ], - "time": "2024-07-03T05:08:43+00:00" + "time": "2025-02-07T04:59:16+00:00" }, { "name": "phpunit/php-timer", - "version": "7.0.1", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -8628,7 +10571,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" }, "funding": [ { @@ -8636,20 +10579,20 @@ "type": "github" } ], - "time": "2024-07-03T05:09:35+00:00" + "time": "2025-02-07T04:59:38+00:00" }, { "name": "phpunit/phpunit", - "version": "11.5.3", + "version": "12.5.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "30e319e578a7b5da3543073e30002bf82042f701" + "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/30e319e578a7b5da3543073e30002bf82042f701", - "reference": "30e319e578a7b5da3543073e30002bf82042f701", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ba0e923f9d3fc655de22f9547c01d15a41fc93a", + "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a", "shasum": "" }, "require": { @@ -8659,37 +10602,33 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.8", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-invoker": "^5.0.1", - "phpunit/php-text-template": "^4.0.1", - "phpunit/php-timer": "^7.0.1", - "sebastian/cli-parser": "^3.0.2", - "sebastian/code-unit": "^3.0.2", - "sebastian/comparator": "^6.3.0", - "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.0", - "sebastian/exporter": "^6.3.0", - "sebastian/global-state": "^7.0.2", - "sebastian/object-enumerator": "^6.0.1", - "sebastian/type": "^5.1.0", - "sebastian/version": "^5.0.2", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.1", + "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/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/object-enumerator": "^7.0.0", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files" - }, "bin": [ "phpunit" ], "type": "library", "extra": { "branch-alias": { - "dev-main": "11.5-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -8721,7 +10660,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.3" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.4" }, "funding": [ { @@ -8732,37 +10671,105 @@ "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/phpunit/phpunit", "type": "tidelift" } ], - "time": "2025-01-13T09:36:00+00:00" + "time": "2025-12-15T06:05:34+00:00" }, { - "name": "sebastian/cli-parser", - "version": "3.0.2", + "name": "rector/rector", + "version": "2.3.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + "url": "https://github.com/rectorphp/rector.git", + "reference": "f7166355dcf47482f27be59169b0825995f51c7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/f7166355dcf47482f27be59169b0825995f51c7d", + "reference": "f7166355dcf47482f27be59169b0825995f51c7d", "shasum": "" }, "require": { - "php": ">=8.2" + "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.0" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2025-12-25T22:00:18+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "shasum": "" + }, + "require": { + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -8786,152 +10793,51 @@ "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/3.0.2" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - } - ], - "time": "2024-07-03T04:41:36+00:00" - }, - { - "name": "sebastian/code-unit", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", - "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "security": "https://github.com/sebastianbergmann/code-unit/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.2" - }, - "funding": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-12-12T09:59:06+00:00" - }, - { - "name": "sebastian/code-unit-reverse-lookup", - "version": "4.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:45:54+00:00" + "time": "2025-09-14T09:36:45+00:00" }, { "name": "sebastian/comparator", - "version": "6.3.0", + "version": "7.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "d4e47a769525c4dd38cea90e5dcd435ddbbc7115" + "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/d4e47a769525c4dd38cea90e5dcd435ddbbc7115", - "reference": "d4e47a769525c4dd38cea90e5dcd435ddbbc7115", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/dc904b4bb3ab070865fa4068cd84f3da8b945148", + "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/diff": "^6.0", - "sebastian/exporter": "^6.0" + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^11.4" + "phpunit/phpunit": "^12.2" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -8939,7 +10845,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.2-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -8979,41 +10885,53 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.0" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.3" }, "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/comparator", + "type": "tidelift" } ], - "time": "2025-01-06T10:28:19+00:00" + "time": "2025-08-20T11:27:00+00:00" }, { "name": "sebastian/complexity", - "version": "4.0.1", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -9037,7 +10955,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" }, "funding": [ { @@ -9045,33 +10963,33 @@ "type": "github" } ], - "time": "2024-07-03T04:49:50+00:00" + "time": "2025-02-07T04:55:25+00:00" }, { "name": "sebastian/diff", - "version": "6.0.2", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9104,7 +11022,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" }, "funding": [ { @@ -9112,27 +11030,27 @@ "type": "github" } ], - "time": "2024-07-03T04:53:05+00:00" + "time": "2025-02-07T04:55:46+00:00" }, { "name": "sebastian/environment", - "version": "7.2.0", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-posix": "*" @@ -9140,7 +11058,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.2-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -9168,42 +11086,54 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" }, "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/environment", + "type": "tidelift" } ], - "time": "2024-07-03T04:54:44+00:00" + "time": "2025-08-12T14:11:56+00:00" }, { "name": "sebastian/exporter", - "version": "6.3.0", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3" + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3", - "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/recursion-context": "^6.0" + "php": ">=8.3", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.1-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9246,43 +11176,55 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" }, "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": "2024-12-05T09:17:50+00:00" + "time": "2025-09-24T06:16:11+00:00" }, { "name": "sebastian/global-state", - "version": "7.0.2", + "version": "8.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -9308,41 +11250,53 @@ "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/7.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" }, "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": "2024-07-03T04:57:36+00:00" + "time": "2025-08-29T11:29:25+00:00" }, { "name": "sebastian/lines-of-code", - "version": "3.0.1", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9366,7 +11320,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" }, "funding": [ { @@ -9374,34 +11328,34 @@ "type": "github" } ], - "time": "2024-07-03T04:58:38+00:00" + "time": "2025-02-07T04:57:28+00:00" }, { "name": "sebastian/object-enumerator", - "version": "6.0.1", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9424,7 +11378,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" }, "funding": [ { @@ -9432,32 +11386,32 @@ "type": "github" } ], - "time": "2024-07-03T05:00:13+00:00" + "time": "2025-02-07T04:57:48+00:00" }, { "name": "sebastian/object-reflector", - "version": "4.0.1", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -9480,7 +11434,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" }, "funding": [ { @@ -9488,32 +11442,32 @@ "type": "github" } ], - "time": "2024-07-03T05:01:32+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { "name": "sebastian/recursion-context", - "version": "6.0.2", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", - "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9544,40 +11498,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "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/recursion-context", + "type": "tidelift" } ], - "time": "2024-07-03T05:10:34+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { "name": "sebastian/type", - "version": "5.1.0", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac" + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/461b9c5da241511a2a0e8f240814fb23ce5c0aac", - "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9601,37 +11567,49 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" }, "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/type", + "type": "tidelift" } ], - "time": "2024-09-17T13:12:04+00:00" + "time": "2025-08-09T06:57:12+00:00" }, { "name": "sebastian/version", - "version": "5.0.2", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9655,7 +11633,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" }, "funding": [ { @@ -9663,7 +11641,7 @@ "type": "github" } ], - "time": "2024-10-09T05:16:32+00:00" + "time": "2025-02-07T05:00:38+00:00" }, { "name": "staabm/side-effects-detector", @@ -9717,97 +11695,25 @@ ], "time": "2024-10-20T05:08:20+00:00" }, - { - "name": "symfony/yaml", - "version": "v7.2.3", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "ac238f173df0c9c1120f862d0f599e17535a87ec" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/ac238f173df0c9c1120f862d0f599e17535a87ec", - "reference": "ac238f173df0c9c1120f862d0f599e17535a87ec", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "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" - }, - "bin": [ - "Resources/bin/yaml-lint" - ], - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "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": "Loads and dumps YAML files", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/yaml/tree/v7.2.3" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-01-07T12:55:42+00:00" - }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.4", + "version": "0.8.5", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "89f0dea1cb0f0d5744d3ec1764a286af5e006636" + "reference": "cf6fb197b676ba716837c886baca842e4db29005" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/89f0dea1cb0f0d5744d3ec1764a286af5e006636", - "reference": "89f0dea1cb0f0d5744d3ec1764a286af5e006636", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/cf6fb197b676ba716837c886baca842e4db29005", + "reference": "cf6fb197b676ba716837c886baca842e4db29005", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", "phpdocumentor/reflection-docblock": "^5.3.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", "symfony/finder": "^6.4.0 || ^7.0.0" }, "require-dev": { @@ -9844,29 +11750,29 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.4" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.5" }, - "time": "2024-01-05T14:10:56+00:00" + "time": "2025-04-20T20:23:40+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -9888,7 +11794,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/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -9896,7 +11802,69 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "bdbabc199a7ba9965484e4725d66170e5711323b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/bdbabc199a7ba9965484e4725d66170e5711323b", + "reference": "bdbabc199a7ba9965484e4725d66170e5711323b", + "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.1" + }, + "time": "2026-01-08T11:28:40+00:00" } ], "aliases": [], @@ -9906,8 +11874,10 @@ "prefer-lowest": false, "platform": { "php": "^8.2", - "ext-imagick": "*" + "ext-imagick": "*", + "ext-simplexml": "*", + "ext-zip": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/app.php b/config/app.php index 92fe477..c7cb051 100644 --- a/config/app.php +++ b/config/app.php @@ -129,5 +129,28 @@ 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_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null), + 'notifications' => [ + 'battery_low' => [ + 'warn_at_percent' => env('NOTIFICATION_BATTERYLOW_WARNATPERCENT', 20), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Application Version + |-------------------------------------------------------------------------- + | + | This value is the version of your application, which will be used when + | displaying the version number in the UI. This is set during the Docker + | build process from the release tag. + | + */ + + '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 1706b99..d97255a 100644 --- a/config/services.php +++ b/config/services.php @@ -38,7 +38,30 @@ return [ 'trmnl' => [ 'proxy_base_url' => env('TRMNL_PROXY_BASE_URL', 'https://trmnl.app'), 'proxy_refresh_minutes' => env('TRMNL_PROXY_REFRESH_MINUTES', 15), + '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' => [ + 'notifications' => [ + 'url' => env('WEBHOOK_NOTIFICATION_URL', null), + 'topic' => env('WEBHOOK_NOTIFICATION_TOPIC', 'null'), + ], + ], + + 'oidc' => [ + 'enabled' => env('OIDC_ENABLED', false), + // OIDC_ENDPOINT can be either: + // - Base URL: https://your-provider.com (will append /.well-known/openid-configuration) + // - Full well-known URL: https://your-provider.com/.well-known/openid-configuration + 'endpoint' => env('OIDC_ENDPOINT'), + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'redirect' => env('APP_URL', 'http://localhost:8000').'/auth/oidc/callback', + 'scopes' => explode(',', env('OIDC_SCOPES', 'openid,profile,email')), ], ]; diff --git a/config/sidecar-browsershot.php b/config/sidecar-browsershot.php new file mode 100644 index 0000000..217f3ef --- /dev/null +++ b/config/sidecar-browsershot.php @@ -0,0 +1,56 @@ + env('SIDECAR_BROWSERSHOT_MEMORY', 2048), + + /** + * The default ephemeral storage available to SidecarBrowsershot, in megabytes. (Defaults to 512MB) + * + * @see https://hammerstone.dev/sidecar/docs/main/functions/customization#storage + */ + 'storage' => env('SIDECAR_BROWSERSHOT_STORAGE', 512), + + /** + * The default timeout to use for SidecarBrowsershot, in seconds. (Defaults to 300) + * + * @see https://hammerstone.dev/sidecar/docs/main/functions/customization#timeout + */ + 'timeout' => env('SIDECAR_BROWSERSHOT_TIMEOUT', 300), + + /** + * Define the number of warming instances to boot. + * + * @see https://hammerstone.dev/sidecar/docs/main/functions/warming + */ + 'warming' => env('SIDECAR_BROWSERSHOT_WARMING_INSTANCES', 0), + + /** + * AWS Layers to use by the Lambda function. + * Defaults to "shelfio/chrome-aws-lambda-layer" and "sidecar-browsershot-layer" in your respective AWS region. + * + * If you customize this, you must include both "sidecar-browsershot-layer" and "shelfio/chrome-aws-lambda-layer" + * in your list, as the config overrides the default values. + * (See BrowsershotFunction@layers for more details) + * + * @see https://github.com/shelfio/chrome-aws-lambda-layer + * @see https://github.com/stefanzweifel/sidecar-browsershot-layer + */ + 'layers' => [ + // "arn:aws:lambda:us-east-1:821527532446:layer:sidecar-browsershot-layer:2", + // "arn:aws:lambda:us-east-1:764866452798:layer:chrome-aws-lambda:42", + ], + + /** + * Path to local directory containing fonts to be installed in the Lambda function. + * During deployment, BorwsershotLambda will scan this directory for + * any files and will bundle them into the Lambda function. + */ + 'fonts' => resource_path('sidecar-browsershot/fonts'), +]; diff --git a/config/sidecar.php b/config/sidecar.php new file mode 100644 index 0000000..b825e9d --- /dev/null +++ b/config/sidecar.php @@ -0,0 +1,10 @@ + [ + Wnx\SidecarBrowsershot\Functions\BrowsershotFunction::class, + ], +]; diff --git a/config/trustedproxy.php b/config/trustedproxy.php new file mode 100644 index 0000000..8557288 --- /dev/null +++ b/config/trustedproxy.php @@ -0,0 +1,6 @@ + ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)), +]; diff --git a/database/factories/DeviceLogFactory.php b/database/factories/DeviceLogFactory.php new file mode 100644 index 0000000..10871d0 --- /dev/null +++ b/database/factories/DeviceLogFactory.php @@ -0,0 +1,24 @@ + ['creation_timestamp' => fake()->dateTimeBetween('-1 month', 'now')->getTimestamp(), 'device_status_stamp' => ['wifi_rssi_level' => -65, 'wifi_status' => 'connected', 'refresh_rate' => 900, 'time_since_last_sleep_start' => 901, 'current_fw_version' => '1.5.5', 'special_function' => 'none', 'battery_voltage' => 4.052, 'wakeup_reason' => 'timer', 'free_heap_size' => 215128, 'max_alloc_size' => 192500], 'log_id' => 17, 'log_message' => 'Error fetching API display: 7, detail: HTTP Client failed with error: connection refused(-1)', 'log_codeline' => 586, 'log_sourcefile' => "src\/bl.cpp", 'additional_info' => ['filename_current' => 'UUID.png', 'filename_new' => null, 'retry_attempt' => 5]], + 'device_timestamp' => fake()->dateTimeBetween('-1 month', 'now'), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + 'device_id' => Device::first(), + ]; + } +} diff --git a/database/factories/DeviceModelFactory.php b/database/factories/DeviceModelFactory.php new file mode 100644 index 0000000..ec3f77d --- /dev/null +++ b/database/factories/DeviceModelFactory.php @@ -0,0 +1,38 @@ + + */ +class DeviceModelFactory extends Factory +{ + protected $model = DeviceModel::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->unique()->slug(), + 'label' => $this->faker->words(2, true), + 'description' => $this->faker->sentence(), + 'width' => $this->faker->randomElement([800, 1024, 1280, 1920]), + 'height' => $this->faker->randomElement([480, 600, 720, 1080]), + 'colors' => $this->faker->randomElement([2, 16, 256, 65536]), + 'bit_depth' => $this->faker->randomElement([1, 4, 8, 16]), + 'scale_factor' => $this->faker->randomElement([1, 2, 4]), + 'rotation' => $this->faker->randomElement([0, 90, 180, 270]), + 'mime_type' => $this->faker->randomElement(['image/png', 'image/jpeg', 'image/gif']), + 'offset_x' => $this->faker->numberBetween(-100, 100), + 'offset_y' => $this->faker->numberBetween(-100, 100), + 'published_at' => $this->faker->optional()->dateTimeBetween('-1 year', 'now'), + ]; + } +} diff --git a/database/factories/DevicePaletteFactory.php b/database/factories/DevicePaletteFactory.php new file mode 100644 index 0000000..1d7ed2d --- /dev/null +++ b/database/factories/DevicePaletteFactory.php @@ -0,0 +1,38 @@ + + */ +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/FirmwareFactory.php b/database/factories/FirmwareFactory.php new file mode 100644 index 0000000..f0b27ee --- /dev/null +++ b/database/factories/FirmwareFactory.php @@ -0,0 +1,24 @@ + $this->faker->word(), + 'url' => $this->faker->url(), + 'latest' => $this->faker->boolean(), + 'storage_location' => $this->faker->word(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } +} diff --git a/database/factories/PlaylistFactory.php b/database/factories/PlaylistFactory.php new file mode 100644 index 0000000..637b840 --- /dev/null +++ b/database/factories/PlaylistFactory.php @@ -0,0 +1,27 @@ + $this->faker->words(3, true), + 'is_active' => $this->faker->boolean(80), // 80% chance of being active + 'weekdays' => $this->faker->randomElements(range(0, 6), $this->faker->numberBetween(1, 7)), + 'active_from' => $this->faker->time('H:i:s'), + 'active_until' => $this->faker->time('H:i:s'), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + 'device_id' => Device::factory(), + ]; + } +} diff --git a/database/factories/PlaylistItemFactory.php b/database/factories/PlaylistItemFactory.php new file mode 100644 index 0000000..a7a1d97 --- /dev/null +++ b/database/factories/PlaylistItemFactory.php @@ -0,0 +1,28 @@ + Playlist::factory(), + 'plugin_id' => Plugin::factory(), + 'mashup' => null, + 'order' => $this->faker->numberBetween(0, 100), + 'is_active' => $this->faker->boolean(80), // 80% chance of being active + 'last_displayed_at' => null, + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } +} diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php new file mode 100644 index 0000000..10a1580 --- /dev/null +++ b/database/factories/PluginFactory.php @@ -0,0 +1,52 @@ + $this->faker->uuid(), + 'user_id' => '1', + 'name' => $this->faker->randomElement(['Weather', 'Clock', 'News', 'Stocks', 'Calendar']), + 'data_payload' => null, + 'data_stale_minutes' => $this->faker->numberBetween(5, 300), + 'data_strategy' => $this->faker->randomElement(['polling', 'webhook']), + 'polling_url' => $this->faker->url(), + 'polling_verb' => $this->faker->randomElement(['get', 'post']), + 'polling_header' => null, + 'polling_body' => null, + 'render_markup' => null, + 'render_markup_view' => null, + 'detail_view_route' => null, + '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_03_07_133658_create_plugins_table.php b/database/migrations/2025_03_07_133658_create_plugins_table.php new file mode 100644 index 0000000..7361b7b --- /dev/null +++ b/database/migrations/2025_03_07_133658_create_plugins_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('uuid')->nullable(); + $table->foreignIdFor(User::class)->nullable(); + $table->string('name')->nullable(); + $table->text('data_payload')->nullable(); + $table->integer('data_stale_minutes')->nullable(); + $table->string('data_strategy')->nullable(); + $table->string('polling_url')->nullable(); + $table->string('polling_verb')->nullable(); + $table->string('polling_header')->nullable(); + $table->text('render_markup')->nullable(); + $table->string('render_markup_view')->nullable(); + $table->string('detail_view_route')->nullable(); + $table->string('icon_url')->nullable(); + $table->string('flux_icon_name')->nullable(); + $table->string('author_name')->nullable(); + $table->boolean('is_native')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('plugins'); + } +}; diff --git a/database/migrations/2025_03_12_151937_create_playlists_table.php b/database/migrations/2025_03_12_151937_create_playlists_table.php new file mode 100644 index 0000000..2cc6570 --- /dev/null +++ b/database/migrations/2025_03_12_151937_create_playlists_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('device_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->boolean('is_active')->default(true); + $table->json('weekdays')->nullable(); // Array of weekday numbers (0-6) + $table->time('active_from')->nullable(); + $table->time('active_until')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('playlists'); + } +}; diff --git a/database/migrations/2025_03_12_191757_create_playlist_items_table.php b/database/migrations/2025_03_12_191757_create_playlist_items_table.php new file mode 100644 index 0000000..366d8ae --- /dev/null +++ b/database/migrations/2025_03_12_191757_create_playlist_items_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('playlist_id')->constrained()->onDelete('cascade'); + $table->foreignId('plugin_id')->constrained()->onDelete('cascade'); + $table->integer('order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamp('last_displayed_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('playlist_items'); + } +}; diff --git a/database/migrations/2025_03_14_211414_add_data_payload_updated_at_to_plugins_table.php b/database/migrations/2025_03_14_211414_add_data_payload_updated_at_to_plugins_table.php new file mode 100644 index 0000000..51d7178 --- /dev/null +++ b/database/migrations/2025_03_14_211414_add_data_payload_updated_at_to_plugins_table.php @@ -0,0 +1,22 @@ +timestamp('data_payload_updated_at')->nullable()->after('data_payload'); + }); + } + + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('data_payload_updated_at'); + }); + } +}; diff --git a/database/migrations/2025_03_18_110028_add_refresh_time_to_playlists_table.php b/database/migrations/2025_03_18_110028_add_refresh_time_to_playlists_table.php new file mode 100644 index 0000000..ad0b79d --- /dev/null +++ b/database/migrations/2025_03_18_110028_add_refresh_time_to_playlists_table.php @@ -0,0 +1,28 @@ +integer('refresh_time')->nullable()->after('active_until'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('playlists', function (Blueprint $table) { + $table->dropColumn('refresh_time'); + }); + } +}; diff --git a/database/migrations/2025_04_26_120013_update_plugin_table_correct_recipe_typo.php b/database/migrations/2025_04_26_120013_update_plugin_table_correct_recipe_typo.php new file mode 100644 index 0000000..d6efa6a --- /dev/null +++ b/database/migrations/2025_04_26_120013_update_plugin_table_correct_recipe_typo.php @@ -0,0 +1,47 @@ +where('uuid', $uuid) + ->update([ + 'render_markup_view' => DB::raw("REPLACE(render_markup_view, 'receipts.', 'recipes.')"), + ]); + } + } + + public function down(): void + { + // Revert the typo correction if needed + $pluginUuids = [ + '9e46c6cf-358c-4bfe-8998-436b3a207fec', // Γ–BB Departures + '3b046eda-34e9-4232-b935-c33b989a284b', // Weather + '21464b16-5f5a-4099-a967-f5c915e3da54', // Zen Quotes + '8d472959-400f-46ee-afb2-4a9f1cfd521f', // This Day in History + '4349fdad-a273-450b-aa00-3d32f2de788d', // Home Assistant + ]; + + foreach ($pluginUuids as $uuid) { + DB::table('plugins') + ->where('uuid', $uuid) + ->update([ + 'render_markup_view' => DB::raw("REPLACE(render_markup_view, 'recipes.', 'receipts.')"), + ]); + } + } +}; diff --git a/database/migrations/2025_05_05_151823_add_device_dimensions_to_devices_table.php b/database/migrations/2025_05_05_151823_add_device_dimensions_to_devices_table.php new file mode 100644 index 0000000..00defd1 --- /dev/null +++ b/database/migrations/2025_05_05_151823_add_device_dimensions_to_devices_table.php @@ -0,0 +1,24 @@ +integer('width')->nullable()->default(800)->after('api_key'); + $table->integer('height')->nullable()->default(480)->after('width'); + }); + } + + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn('width'); + $table->dropColumn('height'); + }); + } +}; diff --git a/database/migrations/2025_05_05_174926_add_mirror_device_id_to_devices_table.php b/database/migrations/2025_05_05_174926_add_mirror_device_id_to_devices_table.php new file mode 100644 index 0000000..f19ea47 --- /dev/null +++ b/database/migrations/2025_05_05_174926_add_mirror_device_id_to_devices_table.php @@ -0,0 +1,29 @@ +foreignId('mirror_device_id')->nullable()->constrained('devices')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropForeign(['mirror_device_id']); + $table->dropColumn('mirror_device_id'); + }); + } +}; diff --git a/database/migrations/2025_05_08_225241_add_assign_new_device_id_to_users_table.php b/database/migrations/2025_05_08_225241_add_assign_new_device_id_to_users_table.php new file mode 100644 index 0000000..242b368 --- /dev/null +++ b/database/migrations/2025_05_08_225241_add_assign_new_device_id_to_users_table.php @@ -0,0 +1,29 @@ +foreignId('assign_new_device_id')->nullable()->constrained('devices')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropForeign(['assign_new_device_id']); + $table->dropColumn('assign_new_device_id'); + }); + } +}; diff --git a/database/migrations/2025_05_10_182724_add_plugin_cache.php b/database/migrations/2025_05_10_182724_add_plugin_cache.php new file mode 100644 index 0000000..a24f436 --- /dev/null +++ b/database/migrations/2025_05_10_182724_add_plugin_cache.php @@ -0,0 +1,28 @@ +string('current_image')->nullable()->after('data_payload_updated_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('current_image'); + }); + } +}; diff --git a/database/migrations/2025_05_10_202133_add_rotate_to_devices_table.php b/database/migrations/2025_05_10_202133_add_rotate_to_devices_table.php new file mode 100644 index 0000000..e439b1b --- /dev/null +++ b/database/migrations/2025_05_10_202133_add_rotate_to_devices_table.php @@ -0,0 +1,22 @@ +integer('rotate')->nullable()->default(0)->after('width'); + }); + } + + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn('rotate'); + }); + } +}; diff --git a/database/migrations/2025_05_13_154942_add_image_format_to_devices_table.php b/database/migrations/2025_05_13_154942_add_image_format_to_devices_table.php new file mode 100644 index 0000000..41dc98c --- /dev/null +++ b/database/migrations/2025_05_13_154942_add_image_format_to_devices_table.php @@ -0,0 +1,28 @@ +string('image_format')->default('auto')->after('rotate'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn('image_format'); + }); + } +}; diff --git a/database/migrations/2025_05_28_232528_create_firmware_table.php b/database/migrations/2025_05_28_232528_create_firmware_table.php new file mode 100644 index 0000000..e238629 --- /dev/null +++ b/database/migrations/2025_05_28_232528_create_firmware_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('version_tag'); + $table->string('url')->nullable(); + $table->boolean('latest')->default(false); + $table->string('storage_location')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('firmware'); + } +}; diff --git a/database/migrations/2025_05_29_010428_add_update_firmware_id_to_devices_table.php b/database/migrations/2025_05_29_010428_add_update_firmware_id_to_devices_table.php new file mode 100644 index 0000000..fc5b99b --- /dev/null +++ b/database/migrations/2025_05_29_010428_add_update_firmware_id_to_devices_table.php @@ -0,0 +1,23 @@ +foreignId('update_firmware_id')->nullable()->constrained('firmware')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropForeign(['update_firmware_id']); + $table->dropColumn('update_firmware_id'); + }); + } +}; diff --git a/database/migrations/2025_06_01_195732_create_device_logs_table.php b/database/migrations/2025_06_01_195732_create_device_logs_table.php new file mode 100644 index 0000000..ef89f3e --- /dev/null +++ b/database/migrations/2025_06_01_195732_create_device_logs_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignIdFor(Device::class)->constrained('devices')->cascadeOnDelete(); + $table->timestamp('device_timestamp'); + $table->json('log_entry'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('device_logs'); + } +}; diff --git a/database/migrations/2025_06_03_141055_add_last_refresh_at_to_devices_table.php b/database/migrations/2025_06_03_141055_add_last_refresh_at_to_devices_table.php new file mode 100644 index 0000000..51b1882 --- /dev/null +++ b/database/migrations/2025_06_03_141055_add_last_refresh_at_to_devices_table.php @@ -0,0 +1,28 @@ +timestamp('last_refreshed_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn('last_refreshed_at'); + }); + } +}; diff --git a/database/migrations/2025_06_10_211056_add_mashup_to_playlist_items_table.php b/database/migrations/2025_06_10_211056_add_mashup_to_playlist_items_table.php new file mode 100644 index 0000000..a8a61d5 --- /dev/null +++ b/database/migrations/2025_06_10_211056_add_mashup_to_playlist_items_table.php @@ -0,0 +1,22 @@ +json('mashup')->nullable(); + }); + } + + public function down(): void + { + Schema::table('playlist_items', function (Blueprint $table) { + $table->dropColumn('mashup'); + }); + } +}; diff --git a/database/migrations/2025_06_13_102932_add_configuration_to_plugins_table.php b/database/migrations/2025_06_13_102932_add_configuration_to_plugins_table.php new file mode 100644 index 0000000..2ed9123 --- /dev/null +++ b/database/migrations/2025_06_13_102932_add_configuration_to_plugins_table.php @@ -0,0 +1,30 @@ +json('configuration_template')->nullable(); + $table->json('configuration')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('configuration_template'); + $table->dropColumn('configuration'); + }); + } +}; diff --git a/database/migrations/2025_06_18_105902_add_battery_notification_sent_to_devices_table.php b/database/migrations/2025_06_18_105902_add_battery_notification_sent_to_devices_table.php new file mode 100644 index 0000000..ffe23bb --- /dev/null +++ b/database/migrations/2025_06_18_105902_add_battery_notification_sent_to_devices_table.php @@ -0,0 +1,28 @@ +boolean('battery_notification_sent')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn('battery_notification_sent'); + }); + } +}; diff --git a/database/migrations/2025_06_20_163742_allow_long_polling_url.php b/database/migrations/2025_06_20_163742_allow_long_polling_url.php new file mode 100644 index 0000000..867837f --- /dev/null +++ b/database/migrations/2025_06_20_163742_allow_long_polling_url.php @@ -0,0 +1,29 @@ +string('polling_url', 1024)->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + // old default string length value in Illuminate + $table->string('polling_url', 255)->nullable()->change(); + }); + } +}; diff --git a/database/migrations/2025_07_02_161953_add_polling_body_to_plugins_table.php b/database/migrations/2025_07_02_161953_add_polling_body_to_plugins_table.php new file mode 100644 index 0000000..c1fbc94 --- /dev/null +++ b/database/migrations/2025_07_02_161953_add_polling_body_to_plugins_table.php @@ -0,0 +1,28 @@ +text('polling_body')->nullable()->after('polling_header'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('polling_body'); + }); + } +}; diff --git a/database/migrations/2025_07_02_231414_add_markup_language_to_plugins_table.php b/database/migrations/2025_07_02_231414_add_markup_language_to_plugins_table.php new file mode 100644 index 0000000..09405be --- /dev/null +++ b/database/migrations/2025_07_02_231414_add_markup_language_to_plugins_table.php @@ -0,0 +1,28 @@ +string('markup_language')->nullable()->after('render_markup'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('markup_language'); + }); + } +}; diff --git a/database/migrations/2025_07_08_191003_add_sleep_mode_and_special_function_to_devices_table.php b/database/migrations/2025_07_08_191003_add_sleep_mode_and_special_function_to_devices_table.php new file mode 100644 index 0000000..adc74e7 --- /dev/null +++ b/database/migrations/2025_07_08_191003_add_sleep_mode_and_special_function_to_devices_table.php @@ -0,0 +1,31 @@ +boolean('sleep_mode_enabled')->default(false); + $table->time('sleep_mode_from')->nullable(); + $table->time('sleep_mode_to')->nullable(); + $table->string('special_function')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn(['sleep_mode_enabled', 'sleep_mode_from', 'sleep_mode_to', 'special_function']); + }); + } +}; diff --git a/database/migrations/2025_07_10_164606_add_pause_until_to_devices_table.php b/database/migrations/2025_07_10_164606_add_pause_until_to_devices_table.php new file mode 100644 index 0000000..69181df --- /dev/null +++ b/database/migrations/2025_07_10_164606_add_pause_until_to_devices_table.php @@ -0,0 +1,22 @@ +dateTime('pause_until')->nullable()->after('last_refreshed_at'); + }); + } + + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn('pause_until'); + }); + } +}; 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 new file mode 100644 index 0000000..7ec1374 --- /dev/null +++ b/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php @@ -0,0 +1,29 @@ +string('oidc_sub')->nullable()->unique()->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropUnique(['oidc_sub']); + $table->dropColumn('oidc_sub'); + }); + } +}; diff --git a/database/migrations/2025_08_07_111635_create_device_models_table.php b/database/migrations/2025_08_07_111635_create_device_models_table.php new file mode 100644 index 0000000..338ca98 --- /dev/null +++ b/database/migrations/2025_08_07_111635_create_device_models_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('name')->unique(); + $table->string('label'); + $table->text('description'); + $table->unsignedInteger('width'); + $table->unsignedInteger('height'); + $table->unsignedInteger('colors'); + $table->unsignedInteger('bit_depth'); + $table->float('scale_factor'); + $table->integer('rotation'); + $table->string('mime_type'); + $table->integer('offset_x')->default(0); + $table->integer('offset_y')->default(0); + $table->timestamp('published_at')->nullable(); + $table->string('source')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('device_models'); + } +}; diff --git a/database/migrations/2025_08_07_131843_add_device_model_id_to_devices_table.php b/database/migrations/2025_08_07_131843_add_device_model_id_to_devices_table.php new file mode 100644 index 0000000..727c545 --- /dev/null +++ b/database/migrations/2025_08_07_131843_add_device_model_id_to_devices_table.php @@ -0,0 +1,29 @@ +foreignId('device_model_id')->nullable()->constrained('device_models')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropForeign(['device_model_id']); + $table->dropColumn('device_model_id'); + }); + } +}; diff --git a/database/migrations/2025_08_16_135740_seed_device_models.php b/database/migrations/2025_08_16_135740_seed_device_models.php new file mode 100644 index 0000000..355227f --- /dev/null +++ b/database/migrations/2025_08_16_135740_seed_device_models.php @@ -0,0 +1,285 @@ + 'og_png', + 'label' => 'TRMNL OG (1-bit)', + 'description' => 'TRMNL OG (1-bit)', + 'width' => 800, + 'height' => 480, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'og_plus', + 'label' => 'TRMNL OG (2-bit)', + 'description' => 'TRMNL OG (2-bit)', + 'width' => 800, + 'height' => 480, + 'colors' => 4, + 'bit_depth' => 2, + 'scale_factor' => 1, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'amazon_kindle_2024', + 'label' => 'Amazon Kindle 2024', + 'description' => 'Amazon Kindle 2024', + 'width' => 1400, + 'height' => 840, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 2.414, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 75, + 'offset_y' => 25, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'amazon_kindle_paperwhite_6th_gen', + 'label' => 'Amazon Kindle PW 6th Gen', + 'description' => 'Amazon Kindle PW 6th Gen', + 'width' => 1024, + 'height' => 768, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'amazon_kindle_paperwhite_7th_gen', + 'label' => 'Amazon Kindle PW 7th Gen', + 'description' => 'Amazon Kindle PW 7th Gen', + 'width' => 1448, + 'height' => 1072, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'inkplate_10', + 'label' => 'Inkplate 10', + 'description' => 'Inkplate 10', + 'width' => 1200, + 'height' => 820, + 'colors' => 8, + 'bit_depth' => 3, + 'scale_factor' => 1, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'amazon_kindle_7', + 'label' => 'Amazon Kindle 7', + 'description' => 'Amazon Kindle 7', + 'width' => 800, + 'height' => 600, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'inky_impression_7_3', + 'label' => 'Inky Impression 7.3', + 'description' => 'Inky Impression 7.3', + 'width' => 800, + 'height' => 480, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'kobo_libra_2', + 'label' => 'Kobo Libra 2', + 'description' => 'Kobo Libra 2', + 'width' => 1680, + 'height' => 1264, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'amazon_kindle_oasis_2', + 'label' => 'Amazon Kindle Oasis 2', + 'description' => 'Amazon Kindle Oasis 2', + 'width' => 1680, + 'height' => 1264, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'kobo_aura_one', + 'label' => 'Kobo Aura One', + 'description' => 'Kobo Aura One', + 'width' => 1872, + 'height' => 1404, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'kobo_aura_hd', + 'label' => 'Kobo Aura HD', + 'description' => 'Kobo Aura HD', + 'width' => 1440, + 'height' => 1080, + 'colors' => 16, + 'bit_depth' => 4, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'inky_impression_13_3', + 'label' => 'Inky Impression 13.3', + 'description' => 'Inky Impression 13.3', + 'width' => 1600, + 'height' => 1200, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]; + + // Upsert by unique 'name' to avoid duplicates and keep data fresh + DeviceModel::query()->upsert( + $deviceModels, + ['name'], + [ + 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', + 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'source', + 'created_at', 'updated_at', + ] + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $names = [ + 'og_png', + 'amazon_kindle_2024', + 'amazon_kindle_paperwhite_6th_gen', + 'amazon_kindle_paperwhite_7th_gen', + 'inkplate_10', + 'amazon_kindle_7', + 'inky_impression_7_3', + 'kobo_libra_2', + 'amazon_kindle_oasis_2', + 'og_plus', + 'kobo_aura_one', + 'kobo_aura_hd', + 'inky_impression_13_3', + ]; + + DeviceModel::query()->whereIn('name', $names)->delete(); + } +}; diff --git a/database/migrations/2025_08_22_231823_add_trmnlp_to_plugins_table.php b/database/migrations/2025_08_22_231823_add_trmnlp_to_plugins_table.php new file mode 100644 index 0000000..4c90d29 --- /dev/null +++ b/database/migrations/2025_08_22_231823_add_trmnlp_to_plugins_table.php @@ -0,0 +1,28 @@ +string('trmnlp_id')->nullable()->after('uuid'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('trmnlp_id'); + }); + } +}; 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 new file mode 100644 index 0000000..f7329c8 --- /dev/null +++ b/database/migrations/2025_10_30_144500_add_no_bleed_and_dark_mode_to_plugins_table.php @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..a998420 --- /dev/null +++ b/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..9262dac --- /dev/null +++ b/database/migrations/2025_11_22_084119_create_device_palettes_table.php @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..1993fcf --- /dev/null +++ b/database/migrations/2025_11_22_084208_add_palette_id_to_device_models_table.php @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..3a47afe --- /dev/null +++ b/database/migrations/2025_11_22_084211_add_palette_id_to_devices_table.php @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..c198d81 --- /dev/null +++ b/database/migrations/2025_11_22_084425_seed_palettes_and_relationships.php @@ -0,0 +1,124 @@ + '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 new file mode 100644 index 0000000..8a92627 --- /dev/null +++ b/database/migrations/2025_12_02_154228_add_timezone_to_users_table.php @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..558fe2c --- /dev/null +++ b/database/migrations/2026_01_05_153321_add_plugin_type_to_plugins_table.php @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..d230657 --- /dev/null +++ b/database/migrations/2026_01_08_173521_add_kind_to_device_models_table.php @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..9769505 --- /dev/null +++ b/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php @@ -0,0 +1,60 @@ +select('user_id', 'trmnlp_id', DB::raw('COUNT(*) as count')) + ->whereNotNull('trmnlp_id') + ->groupBy('user_id', 'trmnlp_id') + ->having('count', '>', 1) + ->get(); + + // For each duplicate combination, keep the first one (by id) and set others to null + foreach ($duplicates as $duplicate) { + $plugins = DB::table('plugins') + ->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; + } + + DB::table('plugins') + ->where('id', $plugin->id) + ->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 new file mode 100644 index 0000000..0a527d7 --- /dev/null +++ b/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php @@ -0,0 +1,28 @@ +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/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 1ab9ab8..c7125c5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -3,6 +3,7 @@ namespace Database\Seeders; use App\Models\Device; +use App\Models\Plugin; use App\Models\User; use Illuminate\Database\Seeder; @@ -22,7 +23,19 @@ class DatabaseSeeder extends Seeder 'password' => bcrypt('admin@example.com'), ]); + Device::factory(1)->create([ + 'mac_address' => '00:00:00:00:00:00', + 'api_key' => 'test-api-key', + ]); + // Device::factory(5)->create(); + + // Plugin::factory(3)->create(); + + $this->call([ + ExampleRecipesSeeder::class, + // MashupPocSeeder::class, + ]); } } } diff --git a/database/seeders/ExampleRecipesSeeder.php b/database/seeders/ExampleRecipesSeeder.php new file mode 100644 index 0000000..5474615 --- /dev/null +++ b/database/seeders/ExampleRecipesSeeder.php @@ -0,0 +1,185 @@ + '9e46c6cf-358c-4bfe-8998-436b3a207fec', + 'name' => 'Γ–BB Departures', + 'user_id' => $user_id, + 'data_payload' => null, + 'data_stale_minutes' => 15, + 'data_strategy' => 'polling', + 'polling_url' => 'https://dbf.finalrewind.org/Wien%20Hbf.json?detailed=1&version=3&limit=8&admode=dep&hafas=%C3%96BB&platforms=1%2C2', + 'polling_verb' => 'get', + 'polling_header' => null, + 'render_markup' => null, + 'render_markup_view' => 'recipes.train', + 'detail_view_route' => null, + 'icon_url' => null, + 'flux_icon_name' => 'train-front', + ] + ); + + Plugin::updateOrCreate( + [ + 'uuid' => '3b046eda-34e9-4232-b935-c33b989a284b', + 'name' => 'Weather', + 'user_id' => $user_id, + 'data_payload' => null, + 'data_stale_minutes' => 60, + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=48.2083&lon=16.3731', + 'polling_verb' => 'get', + 'polling_header' => null, + 'render_markup' => null, + 'render_markup_view' => 'recipes.weather', + 'detail_view_route' => null, + 'icon_url' => null, + 'flux_icon_name' => 'sun', + ] + ); + + Plugin::updateOrCreate( + [ + 'uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54', + 'name' => 'Zen Quotes', + 'user_id' => $user_id, + 'data_payload' => null, + 'data_stale_minutes' => 720, + 'data_strategy' => 'polling', + 'polling_url' => 'https://zenquotes.io/api/today', + 'polling_verb' => 'get', + 'polling_header' => null, + 'render_markup' => null, + 'render_markup_view' => 'recipes.zen', + 'detail_view_route' => null, + 'icon_url' => null, + 'flux_icon_name' => 'chat-bubble-bottom-center', + ] + ); + + Plugin::updateOrCreate( + [ + 'uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f', + 'name' => 'This Day in History', + 'user_id' => $user_id, + 'data_payload' => null, + 'data_stale_minutes' => 720, + 'data_strategy' => 'polling', + 'polling_url' => 'https://raw.githubusercontent.com/jvivona/tidbyt-data/refs/heads/main/thisdayinhistwikipedia/thisdayinhist.json', + 'polling_verb' => 'get', + 'polling_header' => null, + 'render_markup' => null, + 'render_markup_view' => 'recipes.day-in-history', + 'detail_view_route' => null, + 'icon_url' => null, + 'flux_icon_name' => 'calendar', + ] + ); + + Plugin::updateOrCreate( + [ + 'uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d', + 'name' => 'Home Assistant', + 'user_id' => $user_id, + 'data_payload' => null, + 'data_stale_minutes' => 30, + 'data_strategy' => 'polling', + 'polling_url' => 'http://raspberrypi.local:8123/api/states', + 'polling_verb' => 'get', + 'polling_header' => 'Authorization: Bearer YOUR_API_KEY', + 'render_markup' => null, + 'render_markup_view' => 'recipes.home-assistant', + 'detail_view_route' => null, + 'icon_url' => null, + 'flux_icon_name' => 'thermometer', + ] + ); + + Plugin::updateOrCreate( + [ + 'uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80', + 'name' => 'Sunrise/Sunset', + 'user_id' => $user_id, + 'data_payload' => null, + 'data_stale_minutes' => 720, + 'data_strategy' => 'polling', + 'polling_url' => 'https://suntracker.me/?lat=48.2083&lon=16.3731', + 'polling_verb' => 'get', + 'polling_header' => null, + 'render_markup' => null, + 'render_markup_view' => 'recipes.sunrise-sunset', + 'detail_view_route' => null, + 'icon_url' => null, + 'flux_icon_name' => 'sunrise', + ] + ); + + Plugin::updateOrCreate( + [ + 'uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe', + 'name' => 'Pollen Forecast', + 'user_id' => $user_id, + 'data_payload' => null, + 'data_stale_minutes' => 720, + 'data_strategy' => 'polling', + 'polling_url' => 'https://air-quality-api.open-meteo.com/v1/air-quality?latitude=48.2083&longitude=16.3731&hourly=alder_pollen,birch_pollen,grass_pollen,mugwort_pollen,ragweed_pollen¤t=alder_pollen,birch_pollen,grass_pollen,mugwort_pollen,ragweed_pollen&timezone=Europe%2FVienna&forecast_days=2', + 'polling_verb' => 'get', + 'polling_header' => null, + 'render_markup' => null, + 'render_markup_view' => 'recipes.pollen-forecast-eu', + 'detail_view_route' => null, + 'icon_url' => null, + '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/database/seeders/MashupPocSeeder.php b/database/seeders/MashupPocSeeder.php new file mode 100644 index 0000000..35060f8 --- /dev/null +++ b/database/seeders/MashupPocSeeder.php @@ -0,0 +1,50 @@ + 1, + 'name' => 'Mashup Test Playlist', + 'is_active' => true, + ]); + + // Create a playlist item with 1Tx1B layout using the new JSON structure + PlaylistItem::createMashup( + playlist: $playlist, + layout: '1Tx1B', + pluginIds: [2, 3], // Top and bottom plugins + name: 'Mashup 1Tx1B', + order: 1 + ); + + // Create another playlist item with 2x2 layout + PlaylistItem::createMashup( + playlist: $playlist, + layout: '1Lx1R', + pluginIds: [2, 6], // All four quadrants + name: 'Mashup Quadrant', + order: 2 + ); + + // Create a single plugin item (no mashup) + PlaylistItem::create([ + 'playlist_id' => $playlist->id, + 'plugin_id' => 1, + 'mashup' => null, + 'is_active' => true, + 'order' => 3, + ]); + } +} diff --git a/database/storage/.gitkeep b/database/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index d5fa69e..5978037 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,12 +4,18 @@ services: context: . dockerfile: Dockerfile ports: - - "4567:80" + - "4567:8080" environment: #- APP_KEY= + - PHP_OPCACHE_ENABLE=1 - TRMNL_PROXY_REFRESH_MINUTES=15 - # volumes: - # - ./database/database.sqlite:/var/www/html/database/database.sqlite - # - ./storage:/var/www/html/storage + - DB_DATABASE=database/storage/database.sqlite + volumes: + - database:/var/www/html/database/storage + - storage:/var/www/html/storage/app/public/images/generated restart: unless-stopped #platform: "linux/arm64/v8" +volumes: + database: + storage: + diff --git a/docker/nginx.conf b/docker/nginx.conf deleted file mode 100644 index 5f42b71..0000000 --- a/docker/nginx.conf +++ /dev/null @@ -1,17 +0,0 @@ -server { - listen 80; - server_name _; - root /var/www/html/public; - index index.php; - - location / { - try_files $uri $uri/ /index.php?$query_string; - } - - location ~ \.php$ { - fastcgi_pass 127.0.0.1:9000; - fastcgi_index index.php; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - include fastcgi_params; - } -} \ No newline at end of file diff --git a/docker/php.ini b/docker/php.ini deleted file mode 100644 index 1b12c3e..0000000 --- a/docker/php.ini +++ /dev/null @@ -1,14 +0,0 @@ -[PHP] -memory_limit = 256M -max_execution_time = 60 -upload_max_filesize = 50M -post_max_size = 50M - -[opcache] -opcache.enable=1 -opcache.memory_consumption=128 -opcache.interned_strings_buffer=8 -opcache.max_accelerated_files=4000 -opcache.revalidate_freq=60 -opcache.fast_shutdown=1 -opcache.enable_cli=1 \ No newline at end of file diff --git a/docker/prod/docker-compose.yml b/docker/prod/docker-compose.yml new file mode 100644 index 0000000..38cac0e --- /dev/null +++ b/docker/prod/docker-compose.yml @@ -0,0 +1,18 @@ +services: + app: + image: ghcr.io/usetrmnl/byos_laravel:latest + ports: + - "4567:8080" + environment: + #- APP_KEY= + - PHP_OPCACHE_ENABLE=1 + - TRMNL_PROXY_REFRESH_MINUTES=15 + - DB_DATABASE=database/storage/database.sqlite + volumes: + - database:/var/www/html/database/storage + - storage:/var/www/html/storage/app/public/images/generated + restart: unless-stopped +volumes: + database: + storage: + diff --git a/docker/supervisord.conf b/docker/supervisord.conf deleted file mode 100644 index 07352b4..0000000 --- a/docker/supervisord.conf +++ /dev/null @@ -1,56 +0,0 @@ -[supervisord] -nodaemon=true -user=root -logfile=/var/log/supervisor/supervisord.log -pidfile=/var/run/supervisord.pid - -[program:php-fpm] -command=php-fpm -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 - -[program:nginx] -command=nginx -g 'daemon off;' -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 - -[program:laravel-queue] -command=php /var/www/html/artisan queue:work -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -stopwaitsecs=3600 - -[program:laravel-scheduler] -command=php /var/www/html/artisan schedule:work -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 - -[program:laravel-setup] -command=/bin/sh -c "php /var/www/html/artisan storage:link >> /tmp/storage-link.done" -autostart=true -autorestart=false -startsecs=0 -exitcodes=0 -stdout_logfile=/dev/stdout -stderr_logfile=/dev/stderr - -[program:laravel-db-migrate] -command=/bin/sh -c "php /var/www/html/artisan migrate --force >> /tmp/migrate.done" -autostart=true -autorestart=false -startsecs=0 -exitcodes=0 -stdout_logfile=/dev/stdout -stderr_logfile=/dev/stderr diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..40bcbd3 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,92 @@ +## Local Development + +#### Requirements + +* PHP >= 8.2 +* ext-imagick +* puppeteer [see Browsershot docs](https://spatie.be/docs/browsershot/v4/requirements) + +#### Clone the repository + +```bash +git clone git@github.com:usetrmnl/byos_laravel.git +``` + +#### Copy environment file + +```bash +cp .env.example .env +``` + +#### Install dependencies and build frontend + +```bash +composer install +npm i +npm run build +``` + +#### Generate application key + +```bash +php artisan key:generate +``` + +#### Run migrations + +```bash +php artisan migrate --seed +``` + +#### Link storage to expose public assets + +```bash +php artisan storage:link +``` + +#### Run the server + +To expose the built-in server to the local network, you can run the following command: + +```bash +php artisan serve --host=0.0.0.0 --port 4567 +``` + +--- + +## Docker +Use the provided Dockerfile, or docker-compose file to run the server in a container. + +#### .devcontainer + +Open this repository in Visual Studio Code with the Dev Containers extension installed. This will automatically build the devcontainer and start the server. + +Copy the .env.example.local to .env: + +```bash +cp .env.example.local .env +``` + +Run migrations and seed the database: + +```bash +php artisan migrate --seed +``` + +Link storage to expose public assets: + +```bash +php artisan storage:link +``` + +Server is ready. Visit tab "Ports" in VSCode and visit the "Forwarded Address" in your browser. + +Login with user / password `admin@example.com` / `admin@example.com` + +##### After Pull: Install Packages and Build Frontend + +```bash +composer install +npm i +npm run build +``` diff --git a/lang/de/custom_plugins.php b/lang/de/custom_plugins.php new file mode 100644 index 0000000..3fd8785 --- /dev/null +++ b/lang/de/custom_plugins.php @@ -0,0 +1,7 @@ + 'heute', + 'tomorrow' => 'morgen', + 'yesterday' => 'gestern', +]; diff --git a/package-lock.json b/package-lock.json index 5b1bbf9..e722432 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,31 @@ { - "name": "laravel-trmnl-server", + "name": "laravel", "lockfileVersion": 3, "requires": true, "packages": { "": { "dependencies": { - "@tailwindcss/vite": "^4.0.7", + "@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", "autoprefixer": "^10.4.20", - "axios": "^1.7.4", + "axios": "^1.8.2", + "codemirror": "^6.0.2", "concurrently": "^9.0.1", - "laravel-vite-plugin": "^1.0", - "puppeteer": "^24.3.0", + "laravel-vite-plugin": "^2.0", + "puppeteer": "24.30.0", "tailwindcss": "^4.0.7", - "vite": "^6.0" + "vite": "^7.0.4" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", @@ -21,32 +34,196 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "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.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "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==", "cpu": [ "ppc64" ], @@ -60,9 +237,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -76,9 +253,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "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==", "cpu": [ "arm64" ], @@ -92,9 +269,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "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==", "cpu": [ "x64" ], @@ -108,9 +285,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -124,9 +301,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -140,9 +317,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -156,9 +333,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -172,9 +349,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -188,9 +365,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -204,9 +381,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "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==", "cpu": [ "ia32" ], @@ -220,9 +397,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "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==", "cpu": [ "loong64" ], @@ -236,9 +413,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -252,9 +429,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "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==", "cpu": [ "ppc64" ], @@ -268,9 +445,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -284,9 +461,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -300,9 +477,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -316,9 +493,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "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==", "cpu": [ "arm64" ], @@ -332,9 +509,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -348,9 +525,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -364,9 +541,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -379,10 +556,26 @@ "node": ">=18" } }, + "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==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -396,9 +589,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -412,9 +605,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -428,9 +621,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -443,18 +636,149 @@ "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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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==", + "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.7.1", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.7.1.tgz", - "integrity": "sha512-MK7rtm8JjaxPN7Mf1JdZIZKPD2Z+W7osvrC1vjpvfOX1K0awDIHYbNi89f7eotp7eMUn2shWnt03HwVbriXtKQ==", + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", + "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.0", + "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", - "semver": "^7.7.0", - "tar-fs": "^3.0.8", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { @@ -465,9 +789,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz", - "integrity": "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA==", + "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==", "cpu": [ "arm" ], @@ -478,9 +802,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.9.tgz", - "integrity": "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg==", + "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==", "cpu": [ "arm64" ], @@ -491,9 +815,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz", - "integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", "cpu": [ "arm64" ], @@ -504,9 +828,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz", - "integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", "cpu": [ "x64" ], @@ -517,9 +841,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.9.tgz", - "integrity": "sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw==", + "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==", "cpu": [ "arm64" ], @@ -530,9 +854,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.9.tgz", - "integrity": "sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", "cpu": [ "x64" ], @@ -543,9 +867,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.9.tgz", - "integrity": "sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg==", + "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==", "cpu": [ "arm" ], @@ -556,9 +880,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.9.tgz", - "integrity": "sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA==", + "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==", "cpu": [ "arm" ], @@ -569,9 +893,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz", - "integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==", + "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==", "cpu": [ "arm64" ], @@ -582,9 +906,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz", - "integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==", + "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==", "cpu": [ "arm64" ], @@ -594,10 +918,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.9.tgz", - "integrity": "sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg==", + "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==", "cpu": [ "loong64" ], @@ -607,10 +931,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.9.tgz", - "integrity": "sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA==", + "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==", "cpu": [ "ppc64" ], @@ -621,9 +945,22 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.9.tgz", - "integrity": "sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg==", + "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==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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==", "cpu": [ "riscv64" ], @@ -634,9 +971,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.9.tgz", - "integrity": "sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ==", + "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==", "cpu": [ "s390x" ], @@ -660,9 +997,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz", - "integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==", + "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==", "cpu": [ "x64" ], @@ -672,10 +1009,23 @@ "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.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz", - "integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==", + "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==", "cpu": [ "arm64" ], @@ -686,9 +1036,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.9.tgz", - "integrity": "sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w==", + "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==", "cpu": [ "ia32" ], @@ -698,10 +1048,23 @@ "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.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz", - "integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==", + "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==", "cpu": [ "x64" ], @@ -712,42 +1075,47 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.9.tgz", - "integrity": "sha512-tOJvdI7XfJbARYhxX+0RArAhmuDcczTC46DGCEziqxzzbIaPnfYaIyRT31n4u8lROrsO7Q6u/K9bmQHL2uL1bQ==", + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", "license": "MIT", "dependencies": { - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", - "tailwindcss": "4.0.9" + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.9.tgz", - "integrity": "sha512-eLizHmXFqHswJONwfqi/WZjtmWZpIalpvMlNhTM99/bkHtUs6IqgI1XQ0/W5eO2HiRQcIlXUogI2ycvKhVLNcA==", + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.0.9", - "@tailwindcss/oxide-darwin-arm64": "4.0.9", - "@tailwindcss/oxide-darwin-x64": "4.0.9", - "@tailwindcss/oxide-freebsd-x64": "4.0.9", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.9", - "@tailwindcss/oxide-linux-arm64-gnu": "4.0.9", - "@tailwindcss/oxide-linux-arm64-musl": "4.0.9", - "@tailwindcss/oxide-linux-x64-gnu": "4.0.9", - "@tailwindcss/oxide-linux-x64-musl": "4.0.9", - "@tailwindcss/oxide-win32-arm64-msvc": "4.0.9", - "@tailwindcss/oxide-win32-x64-msvc": "4.0.9" + "@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" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.9.tgz", - "integrity": "sha512-YBgy6+2flE/8dbtrdotVInhMVIxnHJPbAwa7U1gX4l2ThUIaPUp18LjB9wEH8wAGMBZUb//SzLtdXXNBHPUl6Q==", + "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==", "cpu": [ "arm64" ], @@ -761,9 +1129,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.9.tgz", - "integrity": "sha512-pWdl4J2dIHXALgy2jVkwKBmtEb73kqIfMpYmcgESr7oPQ+lbcQ4+tlPeVXaSAmang+vglAfFpXQCOvs/aGSqlw==", + "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==", "cpu": [ "arm64" ], @@ -777,9 +1145,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.9.tgz", - "integrity": "sha512-4Dq3lKp0/C7vrRSkNPtBGVebEyWt9QPPlQctxJ0H3MDyiQYvzVYf8jKow7h5QkWNe8hbatEqljMj/Y0M+ERYJg==", + "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==", "cpu": [ "x64" ], @@ -793,9 +1161,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.9.tgz", - "integrity": "sha512-k7U1RwRODta8x0uealtVt3RoWAWqA+D5FAOsvVGpYoI6ObgmnzqWW6pnVwz70tL8UZ/QXjeMyiICXyjzB6OGtQ==", + "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==", "cpu": [ "x64" ], @@ -809,9 +1177,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.9.tgz", - "integrity": "sha512-NDDjVweHz2zo4j+oS8y3KwKL5wGCZoXGA9ruJM982uVJLdsF8/1AeKvUwKRlMBpxHt1EdWJSAh8a0Mfhl28GlQ==", + "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==", "cpu": [ "arm" ], @@ -825,9 +1193,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.9.tgz", - "integrity": "sha512-jk90UZ0jzJl3Dy1BhuFfRZ2KP9wVKMXPjmCtY4U6fF2LvrjP5gWFJj5VHzfzHonJexjrGe1lMzgtjriuZkxagg==", + "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==", "cpu": [ "arm64" ], @@ -841,9 +1209,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.9.tgz", - "integrity": "sha512-3eMjyTC6HBxh9nRgOHzrc96PYh1/jWOwHZ3Kk0JN0Kl25BJ80Lj9HEvvwVDNTgPg154LdICwuFLuhfgH9DULmg==", + "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==", "cpu": [ "arm64" ], @@ -857,9 +1225,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.9.tgz", - "integrity": "sha512-v0D8WqI/c3WpWH1kq/HP0J899ATLdGZmENa2/emmNjubT0sWtEke9W9+wXeEoACuGAhF9i3PO5MeyditpDCiWQ==", + "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==", "cpu": [ "x64" ], @@ -873,9 +1241,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.9.tgz", - "integrity": "sha512-Kvp0TCkfeXyeehqLJr7otsc4hd/BUPfcIGrQiwsTVCfaMfjQZCG7DjI+9/QqPZha8YapLA9UoIcUILRYO7NE1Q==", + "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==", "cpu": [ "x64" ], @@ -888,10 +1256,39 @@ "node": ">= 10" } }, + "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==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "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" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.9.tgz", - "integrity": "sha512-m3+60T/7YvWekajNq/eexjhV8z10rswcz4BC9bioJ7YaN+7K8W2AmLmG0B79H14m6UHE571qB0XsPus4n0QVgQ==", + "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==", "cpu": [ "arm64" ], @@ -905,9 +1302,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.9.tgz", - "integrity": "sha512-dpc05mSlqkwVNOUjGu/ZXd5U1XNch1kHFJ4/cHkZFvaW1RzbHmRt24gvM8/HC6IirMxNarzVw4IXVtvrOoZtxA==", + "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==", "cpu": [ "x64" ], @@ -921,18 +1318,17 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.9.tgz", - "integrity": "sha512-BIKJO+hwdIsN7V6I7SziMZIVHWWMsV/uCQKYEbeiGRDRld+TkqyRRl9+dQ0MCXbhcVr+D9T/qX2E84kT7V281g==", + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.0.9", - "@tailwindcss/oxide": "4.0.9", - "lightningcss": "^1.29.1", - "tailwindcss": "4.0.9" + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" }, "peerDependencies": { - "vite": "^5.2.0 || ^6" + "vite": "^5.2.0 || ^6 || ^7" } }, "node_modules/@tootallnate/quickjs-emscripten": { @@ -942,19 +1338,19 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", - "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", "optional": true, "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/yauzl": { @@ -968,9 +1364,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" @@ -1025,9 +1421,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", "funding": [ { "type": "opencollective", @@ -1044,11 +1440,11 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", - "fraction.js": "^4.3.7", + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", + "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -1062,48 +1458,73 @@ } }, "node_modules/axios": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz", - "integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", - "license": "Apache-2.0" + "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 + } + } }, "node_modules/bare-events": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "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==", "license": "Apache-2.0", - "optional": true + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } }, "node_modules/bare-fs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.1.tgz", - "integrity": "sha512-ilQs4fm/l9eMfWY2dY0WCIUplSUp7U0CT1vrqMg1MUdeZl4fypu5UP0XcDBK5WBQPJAKP1b7XEodISmekH/CEg==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "bare-events": "^2.0.0", + "bare-events": "^2.5.4", "bare-path": "^3.0.0", - "bare-stream": "^2.0.0" + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" }, "engines": { - "bare": ">=1.7.0" + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, "node_modules/bare-os": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.5.1.tgz", - "integrity": "sha512-LvfVNDcWLw2AnIw5f2mWUgumW3I3N/WYGiWeimhQC1Ybt71n2FjlS9GJKeCnFeg1MKZHxzIFmpFnBXDI+sBeFg==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", "license": "Apache-2.0", "optional": true, "engines": { @@ -1121,9 +1542,9 @@ } }, "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1142,6 +1563,25 @@ } } }, + "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", @@ -1152,9 +1592,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -1171,10 +1611,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "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" }, "bin": { "browserslist": "cli.js" @@ -1215,9 +1656,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001702", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz", - "integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==", + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", "funding": [ { "type": "opencollective", @@ -1263,9 +1704,9 @@ } }, "node_modules/chromium-bidi": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-2.1.2.tgz", - "integrity": "sha512-vtRWBK2uImo5/W2oG6/cDkkHSm+2t6VHgnj+Rcwhb0pP74OoUb4GipyRX/T/y39gYQPhioP0DPShn+A7P6CHNw==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-11.0.0.tgz", + "integrity": "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==", "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", @@ -1289,6 +1730,21 @@ "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", @@ -1320,18 +1776,17 @@ } }, "node_modules/concurrently": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz", - "integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "license": "MIT", "dependencies": { - "chalk": "^4.1.2", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", @@ -1370,6 +1825,12 @@ } } }, + "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", @@ -1380,9 +1841,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1420,21 +1881,18 @@ } }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/devtools-protocol": { - "version": "0.0.1402036", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1402036.tgz", - "integrity": "sha512-JwAYQgEvm3yD45CHB+RmF5kMbWtXBaOGwuxa87sZogHcLCv8c/IqnThaoQ1y60d7pXWjSKWQphPEc+1rAScVdg==", + "version": "0.0.1521046", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", + "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", "license": "BSD-3-Clause" }, "node_modules/dunder-proto": { @@ -1452,9 +1910,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.112", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.112.tgz", - "integrity": "sha512-oen93kVyqSb3l+ziUgzIOlWt/oOuy4zRmpwestMn4rhFWAoFJeFuCVte9F2fASjeZZo7l/Cif9TiyrdW4CwEMA==", + "version": "1.5.266", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", + "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -1464,18 +1922,18 @@ "license": "MIT" }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -1495,9 +1953,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -1549,9 +2007,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -1561,31 +2019,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" + "@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" } }, "node_modules/escalade": { @@ -1649,6 +2108,15 @@ "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", @@ -1684,10 +2152,27 @@ "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -1705,14 +2190,15 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -1720,15 +2206,15 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, @@ -1817,9 +2303,9 @@ } }, "node_modules/get-uri": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", - "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", "license": "MIT", "dependencies": { "basic-ftp": "^5.0.2", @@ -1939,14 +2425,10 @@ } }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -1967,9 +2449,9 @@ } }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -1982,9 +2464,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1993,12 +2475,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT" - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -2006,9 +2482,9 @@ "license": "MIT" }, "node_modules/laravel-vite-plugin": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.2.0.tgz", - "integrity": "sha512-R0pJ+IcTVeqEMoKz/B2Ij57QVq3sFTABiFmb06gAwFdivbOgsUtuhX6N2MGLEArajrS3U5JbberzwOe7uXHMHQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz", + "integrity": "sha512-zQuvzWfUKQu9oNVi1o0RZAJCwhGsdhx4NEOyrVQwJHaWDseGP9tl7XUPLY2T8Cj6+IrZ6lmyxlR1KC8unf3RLA==", "license": "MIT", "dependencies": { "picocolors": "^1.0.0", @@ -2018,19 +2494,19 @@ "clean-orphaned-assets": "bin/clean.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0" + "vite": "^7.0.0" } }, "node_modules/lightningcss": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz", - "integrity": "sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "license": "MPL-2.0", "dependencies": { - "detect-libc": "^1.0.3" + "detect-libc": "^2.0.3" }, "engines": { "node": ">= 12.0.0" @@ -2040,22 +2516,43 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.29.1", - "lightningcss-darwin-x64": "1.29.1", - "lightningcss-freebsd-x64": "1.29.1", - "lightningcss-linux-arm-gnueabihf": "1.29.1", - "lightningcss-linux-arm64-gnu": "1.29.1", - "lightningcss-linux-arm64-musl": "1.29.1", - "lightningcss-linux-x64-gnu": "1.29.1", - "lightningcss-linux-x64-musl": "1.29.1", - "lightningcss-win32-arm64-msvc": "1.29.1", - "lightningcss-win32-x64-msvc": "1.29.1" + "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" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.1.tgz", - "integrity": "sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", "cpu": [ "arm64" ], @@ -2073,9 +2570,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.1.tgz", - "integrity": "sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", "cpu": [ "x64" ], @@ -2093,9 +2590,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.1.tgz", - "integrity": "sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", "cpu": [ "x64" ], @@ -2113,9 +2610,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.1.tgz", - "integrity": "sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==", + "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==", "cpu": [ "arm" ], @@ -2133,9 +2630,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.1.tgz", - "integrity": "sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==", + "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==", "cpu": [ "arm64" ], @@ -2153,9 +2650,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.1.tgz", - "integrity": "sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==", + "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==", "cpu": [ "arm64" ], @@ -2173,9 +2670,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", - "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", + "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==", "cpu": [ "x64" ], @@ -2193,9 +2690,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.1.tgz", - "integrity": "sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==", + "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==", "cpu": [ "x64" ], @@ -2213,9 +2710,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.1.tgz", - "integrity": "sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==", + "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==", "cpu": [ "arm64" ], @@ -2233,9 +2730,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.1.tgz", - "integrity": "sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==", + "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==", "cpu": [ "x64" ], @@ -2258,12 +2755,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, "node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -2273,6 +2764,15 @@ "node": ">=12" } }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2316,9 +2816,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -2343,9 +2843,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, "node_modules/normalize-range": { @@ -2441,21 +2941,21 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -2472,7 +2972,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2521,9 +3021,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -2531,17 +3031,17 @@ } }, "node_modules/puppeteer": { - "version": "24.3.1", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.3.1.tgz", - "integrity": "sha512-k0OJ7itRwkr06owp0CP3f/PsRD7Pdw4DjoCUZvjGr+aNgS1z6n/61VajIp0uBjl+V5XAQO1v/3k9bzeZLWs9OQ==", + "version": "24.30.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.30.0.tgz", + "integrity": "sha512-A5OtCi9WpiXBQgJ2vQiZHSyrAzQmO/WDsvghqlN4kgw21PhxA5knHUaUQq/N3EMt8CcvSS0RM+kmYLJmedR3TQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.7.1", - "chromium-bidi": "2.1.2", + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "11.0.0", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1402036", - "puppeteer-core": "24.3.1", + "devtools-protocol": "0.0.1521046", + "puppeteer-core": "24.30.0", "typed-query-selector": "^2.12.0" }, "bin": { @@ -2552,17 +3052,18 @@ } }, "node_modules/puppeteer-core": { - "version": "24.3.1", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.3.1.tgz", - "integrity": "sha512-585ccfcTav4KmlSmYbwwOSeC8VdutQHn2Fuk0id/y/9OoeO7Gg5PK1aUGdZjEmos0TAq+pCpChqFurFbpNd3wA==", + "version": "24.30.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.30.0.tgz", + "integrity": "sha512-2S3Smy0t0W4wJnNvDe7W0bE7wDmZjfZ3ljfMgJd6hn2Hq/f0jgN+x9PULZo2U3fu5UUIJ+JP8cNUGllu8P91Pg==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.7.1", - "chromium-bidi": "2.1.2", - "debug": "^4.4.0", - "devtools-protocol": "0.0.1402036", + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "11.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1521046", "typed-query-selector": "^2.12.0", - "ws": "^8.18.1" + "webdriver-bidi-protocol": "0.3.8", + "ws": "^8.18.3" }, "engines": { "node": ">=18" @@ -2587,12 +3088,12 @@ } }, "node_modules/rollup": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", - "integrity": "sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -2602,32 +3103,35 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.34.9", - "@rollup/rollup-android-arm64": "4.34.9", - "@rollup/rollup-darwin-arm64": "4.34.9", - "@rollup/rollup-darwin-x64": "4.34.9", - "@rollup/rollup-freebsd-arm64": "4.34.9", - "@rollup/rollup-freebsd-x64": "4.34.9", - "@rollup/rollup-linux-arm-gnueabihf": "4.34.9", - "@rollup/rollup-linux-arm-musleabihf": "4.34.9", - "@rollup/rollup-linux-arm64-gnu": "4.34.9", - "@rollup/rollup-linux-arm64-musl": "4.34.9", - "@rollup/rollup-linux-loongarch64-gnu": "4.34.9", - "@rollup/rollup-linux-powerpc64le-gnu": "4.34.9", - "@rollup/rollup-linux-riscv64-gnu": "4.34.9", - "@rollup/rollup-linux-s390x-gnu": "4.34.9", - "@rollup/rollup-linux-x64-gnu": "4.34.9", - "@rollup/rollup-linux-x64-musl": "4.34.9", - "@rollup/rollup-win32-arm64-msvc": "4.34.9", - "@rollup/rollup-win32-ia32-msvc": "4.34.9", - "@rollup/rollup-win32-x64-msvc": "4.34.9", + "@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", "fsevents": "~2.3.2" } }, "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz", - "integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==", + "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==", "cpu": [ "x64" ], @@ -2647,9 +3151,9 @@ } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2659,9 +3163,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2681,12 +3185,12 @@ } }, "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -2727,23 +3231,15 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, "node_modules/streamx": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", - "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", "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": { @@ -2772,6 +3268,12 @@ "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", @@ -2788,24 +3290,28 @@ } }, "node_modules/tailwindcss": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.9.tgz", - "integrity": "sha512-12laZu+fv1ONDRoNR9ipTOpUD7RN9essRVkX36sjxuRUInpN7hIiHN4lBd/SIFjbISvnXzp8h/hXzmU8SQQYhw==", + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar-fs": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", - "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -2836,6 +3342,22 @@ "b4a": "^1.6.4" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -2858,16 +3380,16 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT", "optional": true }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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==", "funding": [ { "type": "opencollective", @@ -2895,20 +3417,23 @@ } }, "node_modules/vite": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", - "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", + "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "postcss": "^8.5.3", - "rollup": "^4.30.1" + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -2917,14 +3442,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -2975,6 +3500,30 @@ "picomatch": "^2.3.1" } }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "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", @@ -2999,9 +3548,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3066,9 +3615,9 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 2393cc5..7262ad1 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,27 @@ "dev": "vite" }, "dependencies": { - "@tailwindcss/vite": "^4.0.7", + "@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", "autoprefixer": "^10.4.20", - "axios": "^1.7.4", + "axios": "^1.8.2", + "codemirror": "^6.0.2", "concurrently": "^9.0.1", - "laravel-vite-plugin": "^1.0", - "puppeteer": "^24.3.0", + "laravel-vite-plugin": "^2.0", + "puppeteer": "24.30.0", "tailwindcss": "^4.0.7", - "vite": "^6.0" + "vite": "^7.0.4" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..e5d841f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - vendor/larastan/larastan/extension.neon + - vendor/nesbot/carbon/extension.neon + +parameters: + + paths: + - app/ + + level: 4 diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..6b39126 --- /dev/null +++ b/pint.json @@ -0,0 +1,34 @@ +{ + "preset": "laravel", + "rules": { + "array_push": true, + "backtick_to_shell_exec": true, + "date_time_immutable": true, + "lowercase_keywords": true, + "lowercase_static_reference": true, + "final_internal_class": true, + "final_public_method_for_abstract_class": true, + "fully_qualified_strict_types": true, + "global_namespace_import": { + "import_classes": true, + "import_constants": true, + "import_functions": true + }, + "mb_str_functions": true, + "modernize_types_casting": true, + "new_with_parentheses": false, + "no_superfluous_elseif": true, + "no_useless_else": true, + "no_multiple_statements_per_line": true, + "ordered_interfaces": true, + "ordered_traits": true, + "protected_to_private": true, + "self_accessor": true, + "self_static_accessor": true, + "strict_comparison": true, + "visibility_required": true, + "increment_style": { + "style": "pre" + } + } +} diff --git a/public/favicon.ico b/public/favicon.ico index e69de29..da17cd5 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/mirror/assets/apple-touch-icon-120x120.png b/public/mirror/assets/apple-touch-icon-120x120.png new file mode 100644 index 0000000..5e51318 Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-120x120.png differ diff --git a/public/mirror/assets/apple-touch-icon-152x152.png b/public/mirror/assets/apple-touch-icon-152x152.png new file mode 100644 index 0000000..9f8d9e3 Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-152x152.png differ diff --git a/public/mirror/assets/apple-touch-icon-167x167.png b/public/mirror/assets/apple-touch-icon-167x167.png new file mode 100644 index 0000000..79d1211 Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-167x167.png differ diff --git a/public/mirror/assets/apple-touch-icon-180x180.png b/public/mirror/assets/apple-touch-icon-180x180.png new file mode 100644 index 0000000..0499ff4 Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-180x180.png differ diff --git a/public/mirror/assets/apple-touch-icon-76x76.png b/public/mirror/assets/apple-touch-icon-76x76.png new file mode 100644 index 0000000..df3943a Binary files /dev/null and b/public/mirror/assets/apple-touch-icon-76x76.png differ diff --git a/public/mirror/assets/favicon-16x16.png b/public/mirror/assets/favicon-16x16.png new file mode 100644 index 0000000..b36f23b Binary files /dev/null and b/public/mirror/assets/favicon-16x16.png differ diff --git a/public/mirror/assets/favicon-32x32.png b/public/mirror/assets/favicon-32x32.png new file mode 100644 index 0000000..ae12e60 Binary files /dev/null and b/public/mirror/assets/favicon-32x32.png differ diff --git a/public/mirror/assets/favicon.ico b/public/mirror/assets/favicon.ico new file mode 100644 index 0000000..da17cd5 Binary files /dev/null and b/public/mirror/assets/favicon.ico differ diff --git a/public/mirror/assets/logo--brand.svg b/public/mirror/assets/logo--brand.svg new file mode 100644 index 0000000..1b84f50 --- /dev/null +++ b/public/mirror/assets/logo--brand.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/mirror/index.html b/public/mirror/index.html new file mode 100644 index 0000000..64746fe --- /dev/null +++ b/public/mirror/index.html @@ -0,0 +1,521 @@ + + + + + + TRMNL BYOS Laravel Mirror + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + \ No newline at end of file diff --git a/public/mirror/manifest.json b/public/mirror/manifest.json new file mode 100644 index 0000000..4d44e44 --- /dev/null +++ b/public/mirror/manifest.json @@ -0,0 +1,7 @@ +{ + "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 new file mode 100644 index 0000000..dde2f14 --- /dev/null +++ b/rector.php @@ -0,0 +1,26 @@ +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 46b9ca1..de95b81 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -59,6 +59,10 @@ @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] { @@ -68,3 +72,39 @@ 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 e69de29..db3ebf3 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -0,0 +1,3 @@ +import { codeEditorFormComponent } from './codemirror-alpine.js'; + +window.codeEditorFormComponent = codeEditorFormComponent; diff --git a/resources/js/codemirror-alpine.js b/resources/js/codemirror-alpine.js new file mode 100644 index 0000000..9ce12f1 --- /dev/null +++ b/resources/js/codemirror-alpine.js @@ -0,0 +1,198 @@ +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 new file mode 100644 index 0000000..f23389f --- /dev/null +++ b/resources/js/codemirror-core.js @@ -0,0 +1,265 @@ +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/app-logo.blade.php b/resources/views/components/app-logo.blade.php index 35c3c07..842020e 100644 --- a/resources/views/components/app-logo.blade.php +++ b/resources/views/components/app-logo.blade.php @@ -2,5 +2,5 @@
- Laravel TRMNL Server + TRMNL BYOS Laravel
diff --git a/resources/views/components/layouts/app/header.blade.php b/resources/views/components/layouts/app/header.blade.php index 513798d..7a9f2c0 100644 --- a/resources/views/components/layouts/app/header.blade.php +++ b/resources/views/components/layouts/app/header.blade.php @@ -4,6 +4,8 @@ @include('partials.head') + + @@ -16,13 +18,17 @@ :current="request()->routeIs('dashboard')"> Dashboard - + Devices - Plugins + :current="request()->routeIs(['plugins.index', 'plugins.markup', 'plugins.api', 'plugins.recipe'])"> + Plugins & Recipes + + + Playlists @@ -62,7 +68,8 @@ - Settings + Settings + Support @@ -87,19 +94,23 @@ - + Dashboard - Devices - - + - Plugins - + Plugins & Recipes + + + Playlists + diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php deleted file mode 100644 index d0e913e..0000000 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ /dev/null @@ -1,132 +0,0 @@ - - - - @include('partials.head') - - - - - - - - - - - - Dashboard - - - - - - - - Repository - - - - Documentation - - - - - - - - - -
-
- - - {{ auth()->user()->initials() }} - - - -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
-
-
- - - - - Settings - - - - -
- @csrf - - {{ __('Log Out') }} - -
-
-
-
- - - - - - - - - - - - -
-
- - - {{ auth()->user()->initials() }} - - - -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
-
-
- - - - - Settings - - - - -
- @csrf - - {{ __('Log Out') }} - -
-
-
-
- - {{ $slot }} - - @fluxScripts - - diff --git a/resources/views/components/layouts/auth/card.blade.php b/resources/views/components/layouts/auth/card.blade.php index 1a316ef..b5a62c6 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/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php index 1c2a9b7..d0ed4cf 100644 --- a/resources/views/components/settings/layout.blade.php +++ b/resources/views/components/settings/layout.blade.php @@ -1,9 +1,11 @@
+ Preferences Profile Password Appearance + Support
diff --git a/resources/views/default-screens/error.blade.php b/resources/views/default-screens/error.blade.php new file mode 100644 index 0000000..be8063a --- /dev/null +++ b/resources/views/default-screens/error.blade.php @@ -0,0 +1,23 @@ +@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 new file mode 100644 index 0000000..3b0ff05 --- /dev/null +++ b/resources/views/default-screens/setup.blade.php @@ -0,0 +1,22 @@ +@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 new file mode 100644 index 0000000..89d6baa --- /dev/null +++ b/resources/views/default-screens/sleep.blade.php @@ -0,0 +1,28 @@ +@props([ + 'noBleed' => false, + 'darkMode' => true, + 'deviceVariant' => 'og', + 'deviceOrientation' => null, + 'colorDepth' => '1bit', + 'scaleLevel' => null, +]) + + + + + +
+ + + +
+ Sleep Mode +
+
+ +
+
diff --git a/resources/views/flux/icon/braces.blade.php b/resources/views/flux/icon/braces.blade.php new file mode 100644 index 0000000..17ff172 --- /dev/null +++ b/resources/views/flux/icon/braces.blade.php @@ -0,0 +1,42 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/flux/icon/flower.blade.php b/resources/views/flux/icon/flower.blade.php new file mode 100644 index 0000000..ddb1459 --- /dev/null +++ b/resources/views/flux/icon/flower.blade.php @@ -0,0 +1,50 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + + + + + diff --git a/resources/views/flux/icon/github.blade.php b/resources/views/flux/icon/github.blade.php new file mode 100644 index 0000000..1463734 --- /dev/null +++ b/resources/views/flux/icon/github.blade.php @@ -0,0 +1,42 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/flux/icon/mashup-1Lx1R.blade.php b/resources/views/flux/icon/mashup-1Lx1R.blade.php new file mode 100644 index 0000000..75d1a3d --- /dev/null +++ b/resources/views/flux/icon/mashup-1Lx1R.blade.php @@ -0,0 +1,39 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + default => 2, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 76 44" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/flux/icon/mashup-1Lx2R.blade.php b/resources/views/flux/icon/mashup-1Lx2R.blade.php new file mode 100644 index 0000000..5794416 --- /dev/null +++ b/resources/views/flux/icon/mashup-1Lx2R.blade.php @@ -0,0 +1,40 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + default => 2, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 76 44" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/mashup-1Tx1B.blade.php b/resources/views/flux/icon/mashup-1Tx1B.blade.php new file mode 100644 index 0000000..c392742 --- /dev/null +++ b/resources/views/flux/icon/mashup-1Tx1B.blade.php @@ -0,0 +1,39 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + default => 2, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 76 44" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/flux/icon/mashup-1Tx2B.blade.php b/resources/views/flux/icon/mashup-1Tx2B.blade.php new file mode 100644 index 0000000..e66990f --- /dev/null +++ b/resources/views/flux/icon/mashup-1Tx2B.blade.php @@ -0,0 +1,40 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + default => 2, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 76 44" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/mashup-1x1.blade.php b/resources/views/flux/icon/mashup-1x1.blade.php new file mode 100644 index 0000000..398b3cf --- /dev/null +++ b/resources/views/flux/icon/mashup-1x1.blade.php @@ -0,0 +1,38 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + default => 2, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 76 44" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + diff --git a/resources/views/flux/icon/mashup-2Lx1R.blade.php b/resources/views/flux/icon/mashup-2Lx1R.blade.php new file mode 100644 index 0000000..9f3a630 --- /dev/null +++ b/resources/views/flux/icon/mashup-2Lx1R.blade.php @@ -0,0 +1,40 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + default => 2, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 76 44" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/mashup-2Tx1B.blade.php b/resources/views/flux/icon/mashup-2Tx1B.blade.php new file mode 100644 index 0000000..2b4d29d --- /dev/null +++ b/resources/views/flux/icon/mashup-2Tx1B.blade.php @@ -0,0 +1,40 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + default => 2, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 76 44" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/mashup-2x2.blade.php b/resources/views/flux/icon/mashup-2x2.blade.php new file mode 100644 index 0000000..71077ca --- /dev/null +++ b/resources/views/flux/icon/mashup-2x2.blade.php @@ -0,0 +1,41 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + default => 2, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 76 44" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + diff --git a/resources/views/flux/icon/sunrise.blade.php b/resources/views/flux/icon/sunrise.blade.php new file mode 100644 index 0000000..e078da6 --- /dev/null +++ b/resources/views/flux/icon/sunrise.blade.php @@ -0,0 +1,48 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + + + diff --git a/resources/views/flux/icon/thermometer.blade.php b/resources/views/flux/icon/thermometer.blade.php new file mode 100644 index 0000000..decfe4f --- /dev/null +++ b/resources/views/flux/icon/thermometer.blade.php @@ -0,0 +1,41 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + diff --git a/resources/views/flux/icon/train-front.blade.php b/resources/views/flux/icon/train-front.blade.php new file mode 100644 index 0000000..253df28 --- /dev/null +++ b/resources/views/flux/icon/train-front.blade.php @@ -0,0 +1,46 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + diff --git a/resources/views/flux/icon/trmnl.blade.php b/resources/views/flux/icon/trmnl.blade.php new file mode 100644 index 0000000..2755f6c --- /dev/null +++ b/resources/views/flux/icon/trmnl.blade.php @@ -0,0 +1,56 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php + if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported.'); + } + + $classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + + $strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + }; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 150 150" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + + + diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 9934b1f..6f8488a 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -17,7 +17,7 @@ new #[Layout('components.layouts.auth')] class extends Component { #[Validate('required|string')] public string $password = ''; - public bool $remember = false; + public bool $remember = true; /** * Handle an incoming authentication request. @@ -70,6 +70,14 @@ new #[Layout('components.layouts.auth')] class extends Component { { return Str::transliterate(Str::lower($this->email) . '|' . request()->ip()); } + + public function mount(): void + { + if (app()->isLocal()) { + $this->email = 'admin@example.com'; + $this->password = 'admin@example.com'; + } + } }; ?>
@@ -81,7 +89,7 @@ new #[Layout('components.layouts.auth')] class extends Component {
+ autocomplete="email" placeholder="admin@example.com"/>
@@ -110,6 +118,29 @@ new #[Layout('components.layouts.auth')] class extends Component {
+ @if (config('services.oidc.enabled')) +
+
+ +
+
+ + {{ __('Or') }} + +
+
+ +
+ + {{ __('Continue with OIDC') }} + +
+ @endif @if (Route::has('register'))
diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php new file mode 100644 index 0000000..fdf7f34 --- /dev/null +++ b/resources/views/livewire/catalog/index.blade.php @@ -0,0 +1,268 @@ +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 new file mode 100644 index 0000000..cc8b070 --- /dev/null +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -0,0 +1,407 @@ +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 new file mode 100644 index 0000000..fad3e53 --- /dev/null +++ b/resources/views/livewire/codemirror.blade.php @@ -0,0 +1,64 @@ +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 7afe67b..7fd48a8 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="styled-container">

Add your first device

+ class="styled-container">
@php $current_image_uuid =$device->current_screen_image; - file_exists('storage/images/generated/' . $current_image_uuid . '.png') ? $file_extension = 'png' : $file_extension = 'bmp'; - $current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension; + if($current_image_uuid) { + $file_extension = file_exists(storage_path('app/public/images/generated/' . $current_image_uuid . '.png')) ? 'png' : 'bmp'; + $current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension; + } else { + $current_image_path = 'storage/images/setup-logo.bmp'; + } @endphp -

{{ $device->name }}

-

{{$device->mac_address}}

- @if($current_image_uuid) +
+ +

{{ $device->name }}

+
+
+ + {{$device->last_refreshed_at?->diffForHumans()}} + +
+
+ + + + View + Show Logs + + +
+
+ @if($device->mirror_device_id) - Current Image + +
+ + + This device is mirrored from + + {{ $device->mirrorDevice->name }} + + +
+
+ @elseif($current_image_path) + +
+
+ Current Image +
+
@endif
diff --git a/resources/views/livewire/device-models/index.blade.php b/resources/views/livewire/device-models/index.blade.php new file mode 100644 index 0000000..a57085b --- /dev/null +++ b/resources/views/livewire/device-models/index.blade.php @@ -0,0 +1,427 @@ + 'required|string|max:255|unique:device_models,name', + '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', + ]; + + public function mount() + { + $this->deviceModels = DeviceModel::all(); + $this->devicePalettes = DevicePalette::all(); + + return view('livewire.device-models.index'); + } + + public $editingDeviceModelId; + + public $viewingDeviceModelId; + + public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): 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->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; + + $this->js('Flux.modal("device-model-modal").show()'); + } +} + +?> + +
+
+
+
+
+

Device Models

+ + + + Devices + Device Palettes + + +
+ + Add Device Model + +
+ @if (session()->has('message')) +
+ + + + + +
+ @endif + + +
+
+ + @if ($viewingDeviceModelId) + View Device Model + @elseif ($editingDeviceModelId) + Edit Device Model + @else + Add Device Model + @endif + +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + image/png + image/bmp + +
+ +
+ + +
+ +
+ + None + @foreach ($devicePalettes as $palette) + {{ $palette->description ?? $palette->name }} ({{ $palette->name }}) + @endforeach + +
+ + @if (!$viewingDeviceModelId) +
+ + {{ $editingDeviceModelId ? 'Update' : 'Create' }} Device Model +
+ @else +
+ + Duplicate +
+ @endif +
+
+
+ + + + + + + + + + + + + + @foreach ($deviceModels as $deviceModel) + + + + + + + + @endforeach + +
+
Description
+
+
Width
+
+
Height
+
+
Bit Depth
+
+
Actions
+
+
+
{{ $deviceModel->label }}
+
{{ Str::limit($deviceModel->name, 50) }}
+
+
+ {{ $deviceModel->width }} + + {{ $deviceModel->height }} + + {{ $deviceModel->bit_depth }} + +
+ + @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 new file mode 100644 index 0000000..28f99c9 --- /dev/null +++ b/resources/views/livewire/device-palettes/index.blade.php @@ -0,0 +1,384 @@ + '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 18ba84f..f9d49ca 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -1,5 +1,10 @@ current_screen_image; $current_image_path = 'images/generated/' . $current_image_uuid . '.png'; + $this->device = $device; $this->name = $device->name; $this->api_key = $device->api_key; $this->friendly_id = $device->friendly_id; $this->mac_address = $device->mac_address; $this->default_refresh_interval = $device->default_refresh_interval; + $this->width = $device->width; + $this->height = $device->height; + $this->rotate = $device->rotate; + $this->image_format = $device->image_format; + $this->device_model_id = $device->device_model_id; + $this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) { + // Put TRMNL models at the top, then sort alphabetically within each group + $isTrmnl = str_starts_with($deviceModel->label, 'TRMNL'); + return $isTrmnl ? '0' . $deviceModel->label : '1' . $deviceModel->label; + }); + $this->playlists = $device->playlists()->with('items.plugin')->orderBy('created_at')->get(); + $this->firmwares = \App\Models\Firmware::orderBy('latest', 'desc')->orderBy('created_at', 'desc')->get(); + $this->selected_firmware_id = $this->firmwares->where('latest', true)->first()?->id; + $this->sleep_mode_enabled = $device->sleep_mode_enabled ?? false; + $this->sleep_mode_from = optional($device->sleep_mode_from)->format('H:i'); + $this->sleep_mode_to = optional($device->sleep_mode_to)->format('H:i'); + $this->special_function = $device->special_function; return view('livewire.devices.configure', [ 'image' => ($current_image_uuid) ? url($current_image_path) : null, @@ -38,6 +88,24 @@ new class extends Component { redirect()->route('devices'); } + public function updatedDeviceModelId() + { + // Convert empty string to null for custom selection + if (empty($this->device_model_id)) { + $this->device_model_id = null; + return; + } + + if ($this->device_model_id) { + $deviceModel = DeviceModel::find($this->device_model_id); + if ($deviceModel) { + $this->width = $deviceModel->width; + $this->height = $deviceModel->height; + $this->rotate = $deviceModel->rotation; + } + } + } + public function updateDevice() { abort_unless(auth()->user()->devices->contains($this->device), 403); @@ -47,38 +115,219 @@ new class extends Component { 'friendly_id' => 'required|string|max:255', 'mac_address' => 'required|string|max:255', 'default_refresh_interval' => 'required|integer|min:1', + 'width' => 'required|integer|min:1', + 'height' => 'required|integer|min:1', + 'rotate' => 'required|integer|min:0|max:359', + 'image_format' => 'required|string', + 'device_model_id' => 'nullable|exists:device_models,id', + 'sleep_mode_enabled' => 'boolean', + 'sleep_mode_from' => 'nullable|date_format:H:i', + 'sleep_mode_to' => 'nullable|date_format:H:i', + 'special_function' => 'nullable|string', ]); + // Convert empty string to null for custom selection + $deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id; + $this->device->update([ 'name' => $this->name, 'friendly_id' => $this->friendly_id, 'mac_address' => $this->mac_address, 'default_refresh_interval' => $this->default_refresh_interval, + 'width' => $this->width, + 'height' => $this->height, + 'rotate' => $this->rotate, + 'image_format' => $this->image_format, + 'device_model_id' => $deviceModelId, + 'sleep_mode_enabled' => $this->sleep_mode_enabled, + 'sleep_mode_from' => $this->sleep_mode_from, + 'sleep_mode_to' => $this->sleep_mode_to, + 'special_function' => $this->special_function, ]); Flux::modal('edit-device')->close(); } + + public function createPlaylist() + { + $this->validate([ + 'playlist_name' => 'required|string|max:255', + 'selected_weekdays' => 'nullable|array', + 'active_from' => 'nullable|date_format:H:i', + 'active_until' => 'nullable|date_format:H:i', + 'refresh_time' => 'nullable|integer|min:60', + ]); + + if ($this->refresh_time < 60) { + $this->refresh_time = null; + } + + if (empty($this->selected_weekdays)){ + $this->selected_weekdays = null; + } + + $this->device->playlists()->create([ + 'name' => $this->playlist_name, + 'weekdays' => $this->selected_weekdays, + 'active_from' => $this->active_from, + 'active_until' => $this->active_until, + 'refresh_time' => $this->refresh_time, + 'is_active' => true, + ]); + + $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); + $this->reset(['playlist_name', 'selected_weekdays', 'active_from', 'active_until']); + Flux::modal('create-playlist')->close(); + } + + public function togglePlaylistActive(Playlist $playlist) + { + $playlist->update(['is_active' => !$playlist->is_active]); + $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); + } + + public function movePlaylistItemUp(PlaylistItem $item) + { + $previousItem = $item->playlist->items() + ->where('order', '<', $item->order) + ->orderBy('order', 'desc') + ->first(); + + if ($previousItem) { + $tempOrder = $previousItem->order; + $previousItem->update(['order' => $item->order]); + $item->update(['order' => $tempOrder]); + $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); + } + } + + public function movePlaylistItemDown(PlaylistItem $item) + { + $nextItem = $item->playlist->items() + ->where('order', '>', $item->order) + ->orderBy('order') + ->first(); + + if ($nextItem) { + $tempOrder = $nextItem->order; + $nextItem->update(['order' => $item->order]); + $item->update(['order' => $tempOrder]); + $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); + } + } + + public function togglePlaylistItemActive(PlaylistItem $item) + { + $item->update(['is_active' => !$item->is_active]); + $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); + } + + public function deletePlaylist(Playlist $playlist) + { + abort_unless(auth()->user()->devices->contains($playlist->device), 403); + $playlist->delete(); + $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); + Flux::modal('delete-playlist-' . $playlist->id)->close(); + } + + public function deletePlaylistItem(PlaylistItem $item) + { + abort_unless(auth()->user()->devices->contains($item->playlist->device), 403); + $item->delete(); + $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); + Flux::modal('delete-playlist-item-' . $item->id)->close(); + } + + public function editPlaylist(Playlist $playlist) + { + $this->validate([ + 'playlist_name' => 'required|string|max:255', + 'selected_weekdays' => 'nullable|array', + 'active_from' => 'nullable|date_format:H:i', + 'active_until' => 'nullable|date_format:H:i', + 'refresh_time' => 'nullable|integer|min:60', + ]); + + if (empty($this->active_from)) { + $this->active_from = null; + } + if (empty($this->active_until)) { + $this->active_until = null; + } + if ($this->refresh_time < 60) { + $this->refresh_time = null; + } + + if (empty($this->selected_weekdays)){ + $this->selected_weekdays = null; + } + + $playlist->update([ + 'name' => $this->playlist_name, + 'weekdays' => $this->selected_weekdays, + 'active_from' => $this->active_from, + 'active_until' => $this->active_until, + 'refresh_time' => $this->refresh_time, + ]); + + $this->playlists = $this->device->playlists()->with('items.plugin')->orderBy('created_at')->get(); + $this->reset(['playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'refresh_time']); + Flux::modal('edit-playlist-' . $playlist->id)->close(); + } + + public function preparePlaylistEdit(Playlist $playlist) + { + $this->playlist_name = $playlist->name; + $this->selected_weekdays = $playlist->weekdays ?? null; + $this->active_from = optional($playlist->active_from)->format('H:i'); + $this->active_until = optional($playlist->active_until)->format('H:i'); + $this->refresh_time = $playlist->refresh_time; + } + + public function updateFirmware() + { + abort_unless(auth()->user()->devices->contains($this->device), 403); + + $this->validate([ + 'selected_firmware_id' => 'required|exists:firmware,id', + ]); + + + if ($this->download_firmware) { + FirmwareDownloadJob::dispatchSync(Firmware::find($this->selected_firmware_id)); + } + + $this->device->update([ + 'update_firmware_id' => $this->selected_firmware_id, + ]); + + Flux::modal('update-firmware')->close(); + } } ?>
-
+ class="styled-container"> +
@php $current_image_uuid =$device->current_screen_image; - file_exists('storage/images/generated/' . $current_image_uuid . '.png') ? $file_extension = 'png' : $file_extension = 'bmp'; - $current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension; + if($current_image_uuid) { + $file_extension = file_exists(storage_path('app/public/images/generated/' . $current_image_uuid . '.png')) ? 'png' : 'bmp'; + $current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension; + } else { + $current_image_path = 'storage/images/setup-logo.bmp'; + } @endphp -
+

{{ $device->name }}

- - {{$device->updated_at->diffForHumans()}} + + {{$device->last_refreshed_at?->diffForHumans()}} @@ -90,23 +339,44 @@ new class extends Component { {{$device->last_firmware_version}} @endif - @if($device->wifiStrengh) + @if($device->wifiStrength) - @endif @if($device->batteryPercent) @endif + @if($device->isPauseActive()) + + + + + @endif
- - - - + + + + + + + Update Firmware + + Show Logs + + Mirror URL + + + + Delete Device + + +
@@ -117,7 +387,6 @@ new class extends Component { Edit TRMNL
- - + + + + + Custom (Manual Dimensions) + @foreach($deviceModels as $deviceModel) + + {{ $deviceModel->label }} ({{ $deviceModel->width }}x{{ $deviceModel->height }}) + + @endforeach + + + @if(empty($device_model_id)) + +
+ + + +
+ + @foreach(\App\Enums\ImageFormat::cases() as $format) + {{$format->label()}} + @endforeach + + @endif + + + + Sleep + Add WiFi + None + + + +
+ +
+
Sleep Mode
+
Enabling Sleep Mode extends battery life
+
+
+ @if($sleep_mode_enabled) +
+ + +
+ @endif
@@ -136,6 +452,39 @@ new class extends Component {
+ +
+
+ Update Firmware + Select a firmware version to update to +
+ +
+
+ + @foreach($firmwares as $firmware) + + {{ $firmware->version_tag }} {{ $firmware->latest ? '(Latest)' : '' }} + + @endforeach + +
+ +
+ + + Check if the Device has no internet connection. + +
+ +
+ + Update Firmware +
+
+
+
+
Delete {{$device->name}}? @@ -153,10 +502,267 @@ new class extends Component { - @if($current_image_uuid) - - Next Image + + @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) + +
+
+ Next Image +
+
+ @endif + + + +
+

Device Playlists

+ + Create Playlist + +
+ @else +
+ +
+ + + This device is mirrored from + + {{ $device->mirrorDevice->name }} + + +
+
+
@endif + + +
+
+ Create Playlist +
+ +
+
+ +
+ +
+ + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + Create Playlist +
+
+
+
+ + @foreach($playlists as $playlist) +
+
+
+

{{ $playlist->name }}

+ +
+
+
+ @if($playlist->weekdays) + {{ implode(', ', collect($playlist->weekdays)->map(fn($day) => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][$day])->toArray()) }} + @endif + @if($playlist->active_from && $playlist->active_until) + + {{ $playlist->active_from->format('H:i') }} - {{ $playlist->active_until->format('H:i') }} + @endif +
+
+ + + + + + +
+
+
+ + +
+
+ Edit Playlist +
+ +
+
+ +
+ +
+ + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + Save Changes +
+
+
+
+ + +
+ Delete {{ $playlist->name }}? +

This will permanently delete this playlist and all its items.

+
+ +
+ + + Cancel + + Delete playlist +
+
+ + + + + + + + + + + @foreach($playlist->items->sortBy('order') as $item) + + + + + + @endforeach + +
+
Plugin
+
+
Status
+
+
Actions
+
+ @if($item->isMashup()) +
+
+
{{ $item->getMashupName() }}
+
+ + {{ collect($item->getMashupPluginIds())->map(fn($id) => App\Models\Plugin::find($id)->name)->join(' | ') }} +
+
+
+ @else +
{{ $item->plugin->name }}
+ @endif +
+ + +
+ @if(!$loop->first) + + @endif + @if(!$loop->last) + + @endif + + + +
+ + +
+ Delete {{ $item->plugin->name }}? +

This will remove this item from the playlist.

+
+ +
+ + + Cancel + + Delete item +
+
+
+
+ @endforeach
diff --git a/resources/views/livewire/devices/logs.blade.php b/resources/views/livewire/devices/logs.blade.php new file mode 100644 index 0000000..18d5bec --- /dev/null +++ b/resources/views/livewire/devices/logs.blade.php @@ -0,0 +1,211 @@ +user()->devices->contains($device), 403); + $this->device = $device; + $this->logs = $device->logs()->latest('device_timestamp')->take(50)->get(); + } +} + +?> + +
+
+
+
+

Device Logs - {{ $device->name }}

+
+ + + + + + + + + + + + + @foreach ($logs as $log) + @php + $message = $log->log_entry['message'] ?? $log->log_entry['log_message'] ?? null; + @endphp + @if (!$message) + @continue + @endif + @php + // Support both previous and revised log formats + $timestamp = $log->log_entry['created_at'] ?? $log->log_entry['creation_timestamp'] ?? null; + $wifiStatus = $log->log_entry['wifi_status'] ?? $log->log_entry['device_status_stamp']['wifi_status'] ?? 'Unknown'; + $wifiRssi = $log->log_entry['wifi_signal'] ?? $log->log_entry['device_status_stamp']['wifi_rssi_level'] ?? null; + $hasDeviceStatus = isset($log->log_entry['device_status_stamp']) || + (isset($log->log_entry['wifi_status']) && $log->log_entry['wifi_status'] !== 'Unknown'); + @endphp + + + + + + + + @if($hasDeviceStatus) + +
+
+ Device Status Details +
+ +
+
+
WiFi Status:
+
{{ $wifiStatus }}
+
+
+
WiFi RSSI:
+
{{ $wifiRssi ?? 'Unknown' }} dBm
+
+
+
Refresh Rate:
+
{{ $log->log_entry['refresh_rate'] ?? $log->log_entry['device_status_stamp']['refresh_rate'] ?? 'Unknown' }}s
+
+
+
Time Since Sleep:
+
{{ $log->log_entry['sleep_duration'] ?? $log->log_entry['device_status_stamp']['time_since_last_sleep_start'] ?? 'Unknown' }}s
+
+
+
Firmware Version:
+
{{ $log->log_entry['firmware_version'] ?? $log->log_entry['device_status_stamp']['current_fw_version'] ?? 'Unknown' }}
+
+
+
Special Function:
+
{{ $log->log_entry['special_function'] ?? $log->log_entry['device_status_stamp']['special_function'] ?? 'None' }}
+
+
+
Battery Voltage:
+
{{ $log->log_entry['battery_voltage'] ?? $log->log_entry['device_status_stamp']['battery_voltage'] ?? 'Unknown' }}V
+
+
+
Wakeup Reason:
+
{{ $log->log_entry['wake_reason'] ?? $log->log_entry['device_status_stamp']['wakeup_reason'] ?? 'Unknown' }}
+
+
+
Free Heap:
+
{{ $log->log_entry['free_heap_size'] ?? $log->log_entry['device_status_stamp']['free_heap_size'] ?? 'Unknown' }} bytes
+
+ @if(isset($log->log_entry['device_status_stamp']['max_alloc_size'])) +
+
Max Alloc Size:
+
{{ $log->log_entry['device_status_stamp']['max_alloc_size'] }} bytes
+
+ @endif +
+ +
+ + + Close + +
+
+
+ @endif + + +
+
+ Log Details +
+ +
+
+
Source File:
+
{{ $log->log_entry['source_path'] ?? $log->log_entry['log_sourcefile'] ?? 'Unknown' }}
+
+
+
Line Number:
+
{{ $log->log_entry['source_line'] ?? $log->log_entry['log_codeline'] ?? 'Unknown' }}
+
+ @if(isset($log->log_entry['additional_info'])) +
+
Additional Info
+
+ @foreach($log->log_entry['additional_info'] as $key => $value) +
+ {{ str_replace('_', ' ', ucfirst($key)) }}: + {{ is_null($value) ? 'None' : $value }} +
+ @endforeach +
+
+ @endif +
+ +
+ + + Close + +
+
+
+ @endforeach + +
+
Device Time
+
+
Log Level
+
+
Device Status
+
+
Message
+
+ @if ($timestamp) + {{ \Carbon\Carbon::createFromTimestamp($timestamp)->setTimezone(config('app.timezone'))->format('Y-m-d H:i:s') }} + @endif + +
+ {{ str_contains(strtolower($message), 'error') ? 'Error' : + (str_contains(strtolower($message), 'warning') ? 'Warning' : 'Info') }} +
+
+
+
+ {{ $wifiStatus }} + @if($wifiRssi) + ({{ $wifiRssi }}dBm) + @endif +
+ @if($hasDeviceStatus) + + + + @endif +
+
+
+ {{ $message }} + + + +
+
+
+
+
diff --git a/resources/views/livewire/devices/manage.blade.php b/resources/views/livewire/devices/manage.blade.php index e3935d0..646adc0 100644 --- a/resources/views/livewire/devices/manage.blade.php +++ b/resources/views/livewire/devices/manage.blade.php @@ -1,6 +1,7 @@ 'required', 'api_key' => 'required', 'default_refresh_interval' => 'required|integer', + 'device_model_id' => 'nullable|exists:device_models,id', + 'mirror_device_id' => 'required_if:is_mirror,true', ]; public function mount() { $this->devices = auth()->user()->devices; + $this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) { + // Put TRMNL models at the top, then sort alphabetically within each group + $isTrmnl = str_starts_with($deviceModel->label, 'TRMNL'); + return $isTrmnl ? '0' . $deviceModel->label : '1' . $deviceModel->label; + }); return view('livewire.devices.manage'); } + public function updatedDeviceModelId(): void + { + // Convert empty string to null for custom selection + if (empty($this->device_model_id)) { + $this->device_model_id = null; + } + } + public function createDevice(): void { $this->validate(); + if ($this->is_mirror) { + // Verify the mirror device belongs to the user and is not a mirror device itself + $mirrorDevice = auth()->user()->devices()->find($this->mirror_device_id); + abort_unless($mirrorDevice, 403, 'Invalid mirror device selected'); + abort_if($mirrorDevice->mirror_device_id !== null, 403, 'Cannot mirror a device that is already a mirror device'); + } + + // Convert empty string to null for custom selection + $deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id; + Device::create([ 'name' => $this->name, 'mac_address' => $this->mac_address, @@ -42,6 +76,8 @@ new class extends Component { 'default_refresh_interval' => $this->default_refresh_interval, 'friendly_id' => $this->friendly_id, 'user_id' => auth()->id(), + 'device_model_id' => $deviceModelId, + 'mirror_device_id' => $this->is_mirror ? $this->mirror_device_id : null, ]); $this->reset(); @@ -62,6 +98,20 @@ new class extends Component { // \App\Jobs\FetchProxyCloudResponses::dispatch(); // } } + + public function pauseDevice($deviceId): void + { + $this->validate([ + 'pause_duration' => 'required|integer', + ]); + $device = auth()->user()->devices()->findOrFail($deviceId); + $pauseUntil = now()->addMinutes($this->pause_duration); + $device->update(['pause_until' => $pauseUntil]); + $this->reset('pause_duration'); + \Flux::modal('pause-device-' . $deviceId)->close(); + $this->devices = auth()->user()->devices; + session()->flash('message', 'Device paused until ' . $pauseUntil->format('H:i')); + } } ?> @@ -71,14 +121,28 @@ new class extends Component { {{--@dump($devices)--}}
-

Devices

+
+

Devices

+ + + + Device Models + Device Palettes + + +
Add Device
@if (session()->has('message')) -
- {{ session('message') }} +
+ + + + +
@endif @@ -116,9 +180,40 @@ new class extends Component {
+ +
+ + Custom (Manual Dimensions) + @if ($deviceModels && $deviceModels->count() > 0) + @foreach($deviceModels as $deviceModel) + + {{ $deviceModel->label }} ({{ $deviceModel->width }}x{{ $deviceModel->height }}) + + @endforeach + @endif + +
+ +
+ +
+ + @if($is_mirror) +
+ + Select a device + @foreach(auth()->user()->devices->where('mirror_device_id', null) as $device) + + {{ $device->name }} ({{ $device->friendly_id }}) + + @endforeach + +
+ @endif +
Create Device @@ -181,15 +276,29 @@ new class extends Component {
- + + + + @if($device->isPauseActive()) + + + + @else + + + + + @endif + - +
@@ -202,4 +311,34 @@ new class extends Component {
+ @foreach ($devices as $device) + +
+
+ Pause +
Select how long to pause screen generation for {{ $device->name }}. +
+
+
+
+ + + + + + + +
+
+ + + Cancel + + Save +
+
+
+
+ @endforeach
diff --git a/resources/views/livewire/playlists/index.blade.php b/resources/views/livewire/playlists/index.blade.php new file mode 100644 index 0000000..6c979e6 --- /dev/null +++ b/resources/views/livewire/playlists/index.blade.php @@ -0,0 +1,353 @@ +devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); + return view('livewire.playlists.index'); + } + + public function togglePlaylistActive(Playlist $playlist) + { + abort_unless(auth()->user()->devices->contains($playlist->device), 403); + $playlist->update(['is_active' => !$playlist->is_active]); + $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); + } + + public function movePlaylistItemUp(PlaylistItem $item) + { + abort_unless(auth()->user()->devices->contains($item->playlist->device), 403); + $previousItem = $item->playlist->items() + ->where('order', '<', $item->order) + ->orderBy('order', 'desc') + ->first(); + + if ($previousItem) { + $tempOrder = $previousItem->order; + $previousItem->update(['order' => $item->order]); + $item->update(['order' => $tempOrder]); + $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); + } + } + + public function movePlaylistItemDown(PlaylistItem $item) + { + abort_unless(auth()->user()->devices->contains($item->playlist->device), 403); + $nextItem = $item->playlist->items() + ->where('order', '>', $item->order) + ->orderBy('order') + ->first(); + + if ($nextItem) { + $tempOrder = $nextItem->order; + $nextItem->update(['order' => $item->order]); + $item->update(['order' => $tempOrder]); + $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); + } + } + + public function togglePlaylistItemActive(PlaylistItem $item) + { + abort_unless(auth()->user()->devices->contains($item->playlist->device), 403); + $item->update(['is_active' => !$item->is_active]); + $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); + } + + public function deletePlaylist(Playlist $playlist) + { + abort_unless(auth()->user()->devices->contains($playlist->device), 403); + $playlist->delete(); + $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); + Flux::modal('delete-playlist-' . $playlist->id)->close(); + } + + public function deletePlaylistItem(PlaylistItem $item) + { + abort_unless(auth()->user()->devices->contains($item->playlist->device), 403); + $item->delete(); + $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); + Flux::modal('delete-playlist-item-' . $item->id)->close(); + } + + public function editPlaylist(Playlist $playlist) + { + abort_unless(auth()->user()->devices->contains($playlist->device), 403); + + $this->validate([ + 'playlist_name' => 'required|string|max:255', + 'selected_weekdays' => 'nullable|array', + 'active_from' => 'nullable|date_format:H:i', + 'active_until' => 'nullable|date_format:H:i', + 'refresh_time' => 'nullable|integer|min:60', + ]); + + if (empty($this->active_from)) { + $this->active_from = null; + } + if (empty($this->active_until)) { + $this->active_until = null; + } + if ($this->refresh_time < 60) { + $this->refresh_time = null; + } + + if (empty($this->selected_weekdays)){ + $this->selected_weekdays = null; + } + + $playlist->update([ + 'name' => $this->playlist_name, + 'weekdays' => $this->selected_weekdays, + 'active_from' => $this->active_from, + 'active_until' => $this->active_until, + 'refresh_time' => $this->refresh_time, + ]); + + $this->devices = auth()->user()->devices()->with(['playlists.items.plugin'])->get(); + $this->reset(['playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'refresh_time']); + Flux::modal('edit-playlist-' . $playlist->id)->close(); + } + + public function preparePlaylistEdit(Playlist $playlist) + { + $this->playlist_name = $playlist->name; + $this->selected_weekdays = $playlist->weekdays ?? null; + $this->active_from = optional($playlist->active_from)->format('H:i'); + $this->active_until = optional($playlist->active_until)->format('H:i'); + $this->refresh_time = $playlist->refresh_time; + } +}; ?> + +
+
+
+
+

Playlists

+
+ + @foreach($devices as $device) + @if($device->playlists->isNotEmpty()) +
+
+

{{ $device->name }}

+ + +
+ +
+ @foreach($device->playlists as $playlist) +
+
+
+

{{ $playlist->name }}

+ +
+
+
+ @if($playlist->weekdays) + {{ implode(', ', collect($playlist->weekdays)->map(fn($day) => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][$day])->toArray()) }} + @endif + @if($playlist->active_from && $playlist->active_until) + + {{ $playlist->active_from->format('H:i') }} - {{ $playlist->active_until->format('H:i') }} + @endif +
+
+ + + + + + +
+
+
+ + + + + + + + + + + @foreach($playlist->items->sortBy('order') as $item) + + + + + + @endforeach + +
+
Plugin / Recipe
+
+
Status
+
+
Actions
+
+ @if($item->isMashup()) +
+
+
{{ $item->getMashupName() }}
+
+ + {{ collect($item->getMashupPluginIds())->map(fn($id) => App\Models\Plugin::find($id)->name)->join(' | ') }} +
+
+
+ @else +
{{ $item->plugin->name }}
+ @endif +
+ + +
+ @if($playlist->items->count() > 1) + @if(!$loop->first) + + @endif + @if(!$loop->last) + + @endif + @endif + + + +
+ + +
+ + @if($item->isMashup()) + Delete {{ $item->getMashupName() }}? + @else + Delete {{ $item->plugin->name }}? + @endif + +

+ @if($item->isMashup()) + This will remove this mashup from the playlist. + @else + This will remove this item from the playlist. + @endif +

+
+ +
+ + + Cancel + + Delete item +
+
+
+
+ + +
+
+ Edit Playlist +
+ +
+
+ +
+ +
+ + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + Save Changes +
+
+
+
+ + +
+ Delete {{ $playlist->name }}? +

This will permanently delete this playlist and all its items.

+
+ +
+ + + Cancel + + Delete playlist +
+
+ @endforeach +
+
+ @endif + @endforeach + + @if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty())) +
+
+

No playlists found

+

Add playlists to your devices to see them here.

+ @if($devices->isNotEmpty()) + + Go to Devices + + @else + + Add Device + + @endif +
+
+ @endif +
+
+
diff --git a/resources/views/livewire/plugins/api.blade.php b/resources/views/livewire/plugins/api.blade.php index 117e348..e445dbf 100644 --- a/resources/views/livewire/plugins/api.blade.php +++ b/resources/views/livewire/plugins/api.blade.php @@ -1,9 +1,13 @@ createToken('api-token', ['update-screen']); } $this->token = $token->plainTextToken; + + $this->devices = auth()->user()->devices?->pluck('id', 'name'); + $this->selected_device = $this->devices->first(); } public function regenerateToken() @@ -26,13 +33,28 @@ new class extends Component {
-

API

+

API + Plugin +

+ +
+ @if(isset($devices)) + + @foreach($devices as $id => $name) + + {{ $id }} + + @endforeach + + @endif +
+

POST - {{route('display.update')}} + {{ route('display.update') }}?device_id={{ $selected_device }}

Headers

@@ -52,5 +74,28 @@ new class extends Component {
+ +
+

+ GETPOST + {{ route('display.status') }}?device_id={{ $selected_device }} +

+
+

Headers

+
Authorization Bearer {{$token ?? '**********'}} + + Regenerate Token + +
+
+
+

Body POST

+
+
+{"default_refresh_interval": 900, "sleep_mode_enabled": true, "pause_until": "2025-07-10T22:00:00+02:00"}
+                    
+
+
+
diff --git a/resources/views/livewire/plugins/config-modal.blade.php b/resources/views/livewire/plugins/config-modal.blade.php new file mode 100644 index 0000000..7aaacbb --- /dev/null +++ b/resources/views/livewire/plugins/config-modal.blade.php @@ -0,0 +1,516 @@ + 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 new file mode 100644 index 0000000..e4ad9df --- /dev/null +++ b/resources/views/livewire/plugins/image-webhook-instance.blade.php @@ -0,0 +1,298 @@ +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 new file mode 100644 index 0000000..3161443 --- /dev/null +++ b/resources/views/livewire/plugins/image-webhook.blade.php @@ -0,0 +1,163 @@ + '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 562ef30..d902183 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -1,32 +1,411 @@ - ['name' => 'Markup', 'icon' => 'code-backet', 'route' => 'plugins.markup'], + ['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'], 'api' => - ['name' => 'API', 'icon' => 'code-backet', 'route' => 'plugins.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 = [ + 'name' => 'required|string|max:255', + 'data_stale_minutes' => 'required|integer|min:1', + 'data_strategy' => 'required|string|in:polling,webhook,static', + 'polling_url' => 'required_if:data_strategy,polling|nullable|url', + 'polling_verb' => 'required|string|in:get,post', + 'polling_header' => 'nullable|string|max:255', + 'polling_body' => 'nullable|string', + ]; + + public function refreshPlugins(): void + { + // Only show recipe plugins in the main list (image_webhook has its own management page) + $userPlugins = auth()->user()?->plugins() + ->where('plugin_type', 'recipe') + ->get() + ->makeHidden(['render_markup', 'data_payload']) + ->toArray(); + $allPlugins = array_merge($this->native_plugins, $userPlugins ?? []); + $allPlugins = array_values($allPlugins); + $allPlugins = $this->sortPlugins($allPlugins); + $this->plugins = $allPlugins; + } + + 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; + } + + public function mount(): void + { + $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); + $this->validate(); + + \App\Models\Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => auth()->id(), + 'name' => $this->name, + 'data_stale_minutes' => $this->data_stale_minutes, + 'data_strategy' => $this->data_strategy, + 'polling_url' => $this->polling_url ?? null, + 'polling_verb' => $this->polling_verb, + 'polling_header' => $this->polling_header, + 'polling_body' => $this->polling_body, + ]); + + $this->reset(['name', 'data_stale_minutes', 'data_strategy', 'polling_url', 'polling_verb', 'polling_header', 'polling_body']); + $this->refreshPlugins(); + + Flux::modal('add-plugin')->close(); + } + + public function seedExamplePlugins(): void + { + Artisan::call(ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]); + $this->refreshPlugins(); + } + + + public function importZip(PluginImportService $pluginImportService): void + { + abort_unless(auth()->user() !== null, 403); + + $this->validate([ + 'zipFile' => 'required|file|mimes:zip|max:10240', // 10MB max + ]); + + try { + $plugin = $pluginImportService->importFromZip($this->zipFile, auth()->user()); + + $this->refreshPlugins(); + $this->reset(['zipFile']); + + Flux::modal('import-zip')->close(); + } catch (\Exception $e) { + $this->addError('zipFile', 'Error installing plugin: ' . $e->getMessage()); + } + } + }; ?> -
+
-

Plugins

+

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 + + + +
+ + + + + + +
+
+ Import Recipe + Beta + + Upload a ZIP archive containing a TRMNL recipe β€” either exported from the cloud service or structured using the trmnlp project structure. +
+ +
+ The archive must at least contain settings.yml and full.liquid files. +{{--

The ZIP file should contain the following structure:

--}} +{{--
--}}
+{{--.--}}
+{{--β”œβ”€β”€ src--}}
+{{--β”‚   β”œβ”€β”€ full.liquid (required)--}}
+{{--β”‚   β”œβ”€β”€ settings.yml (required)--}}
+{{--β”‚   └── ...--}}
+{{--└── ...--}}
+{{--                    
--}} +
+ +
+ Limitations +
    +
  • Only full view will be imported; shared markup will be prepended
  • +
  • Some Liquid filters may be not supported or behave differently
  • +
  • API responses in formats other than JSON are not yet supported
  • +{{--
      --}} +{{--
    • date: "%N" is unsupported. Use date: "u" instead
    • --}} +{{--
    --}} +
+ Please report issues on GitHub. Include your example zip file. +
+ +
+
+ .zip Archive + + @error('zipFile') + + @enderror +
+ +
+ + Import +
+
+
+
+ + +
+
+ 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. +
+
+ +
+
+ + +
+
+ Add Recipe +
+ +
+
+ +
+ +
+ + + + + +
+ + @if($data_strategy === 'polling') +
+ +
+ +
+ + + + +
+ +
+ +
+ + @if($polling_verb === 'post') +
+ +
+ @endif +
+ +
+ @endif + +
+ + Create Recipe +
+
+
+
+ + @php + $allPlugins = $this->plugins; + @endphp +
- @foreach($plugins as $plugin) + @foreach($allPlugins as $index => $plugin)
- - diff --git a/resources/views/livewire/plugins/markup.blade.php b/resources/views/livewire/plugins/markup.blade.php index 7b6d300..cb7823e 100644 --- a/resources/views/livewire/plugins/markup.blade.php +++ b/resources/views/livewire/plugins/markup.blade.php @@ -1,6 +1,7 @@ devices = auth()->user()->devices->pluck('id', 'name'); + } + public function submit() { $this->isLoading = true; $this->validate([ + 'checked_devices' => 'required|array', 'blade_code' => 'required|string' ]); + //only devices that are owned by the user + $this->checked_devices = array_intersect($this->checked_devices, auth()->user()->devices->pluck('id')->toArray()); + try { $rendered = Blade::render($this->blade_code); - -// if (config('app.puppeteer_docker')) { -// GenerateScreenJob::dispatch(auth()->user()->devices()->first()->id, $rendered); -// } else { - GenerateScreenJob::dispatchSync(auth()->user()->devices()->first()->id, $rendered); -// } - + foreach ($this->checked_devices as $device) { + GenerateScreenJob::dispatchSync($device, null, $rendered); + } } catch (\Exception $e) { - $this->addError('error', $e->getMessage()); + $this->addError('generate_screen', $e->getMessage()); } $this->isLoading = false; @@ -36,6 +45,9 @@ new class extends Component { public function renderExample(string $example) { switch ($example) { + case 'helloWorld': + $markup = $this->renderHelloWorld(); + break; case 'quote': $markup = $this->renderQuote(); break; @@ -52,50 +64,72 @@ new class extends Component { $this->blade_code = $markup; } + public function renderHelloWorld(): string + { + return << + + + + TRMNL BYOS Laravel + β€œThis screen was rendered by BYOS Laravel” + Benjamin Nussbaum + + + + + +HTML; + } + public function renderQuote(): string { return << - - - Motivational Quote - β€œI love inside jokes. I hope to be a part of one someday.” - Michael Scott - - - - + + + + + Motivational Quote + β€œI love inside jokes. I hope to be a part of one someday.” + Michael Scott + + + + + HTML; } public function renderTrainMonitor() { return << - - - - - Abfahrt - Aktuell - Zug - Ziel - Steig - - - + + + + + - 08:51 - 08:52 - REX 1 - Vienna Main Station - 3 + Abfahrt + Aktuell + Zug + Ziel + Steig - - - - - + + + + 08:51 + 08:52 + REX 1 + Vienna Main Station + 3 + + + + + + + HTML; } @@ -103,26 +137,28 @@ HTML; public function renderHomeAssistant() { return << - - - - - - - 23.3Β° - - - 47.52 % - - Sensor 1 - - - - - - - + + + + + + + + + 23.3Β° + + + 47.52 % + + Sensor 1 + + + + + + + + HTML; } @@ -133,13 +169,16 @@ HTML;
-

Markup

+

Markup + Plugin +

{{--
--}}
Examples
+ Hello World | Quote | Train Monitor | Temperature Sensors @@ -159,13 +198,26 @@ HTML;
+ + @foreach($devices as $name => $id) + + @endforeach + + + Generate Screen
+ @error('generate_screen') +
+ {{ $message }} +
+ @enderror + {{--
--}}
diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php new file mode 100644 index 0000000..1597d5d --- /dev/null +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -0,0 +1,1062 @@ +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 ?? []; + + if ($this->plugin->render_markup_view) { + try { + $basePath = resource_path('views/' . str_replace('.', '/', $this->plugin->render_markup_view)); + $paths = [ + $basePath . '.blade.php', + $basePath . '.liquid', + ]; + + $this->view_content = null; + foreach ($paths as $path) { + if (file_exists($path)) { + $this->view_content = file_get_contents($path); + break; + } + } + } catch (\Exception $e) { + $this->view_content = null; + } + } else { + $this->markup_code = $this->plugin->render_markup; + $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 + { + $this->name = $this->plugin->name; + $this->data_stale_minutes = $this->plugin->data_stale_minutes; + $this->data_strategy = $this->plugin->data_strategy; + $this->polling_url = $this->plugin->polling_url; + $this->polling_verb = $this->plugin->polling_verb; + $this->polling_header = $this->plugin->polling_header; + $this->polling_body = $this->plugin->polling_body; + $this->data_payload = json_encode($this->plugin->data_payload, JSON_PRETTY_PRINT); + } + + public function saveMarkup(): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + $this->validate(); + $this->plugin->update([ + 'render_markup' => $this->markup_code ?? null, + 'markup_language' => $this->markup_language ?? null + ]); + } + + protected array $rules = [ + 'name' => 'required|string|max:255', + 'data_stale_minutes' => 'required|integer|min:1', + 'data_strategy' => 'required|string|in:polling,webhook,static', + 'polling_url' => 'required_if:data_strategy,polling|nullable', + 'polling_verb' => 'required|string|in:get,post', + 'polling_header' => 'nullable|string|max:255', + 'polling_body' => 'nullable|string', + 'data_payload' => 'required_if:data_strategy,static|nullable|json', + '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', + ]; + + public function editSettings() + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + // Custom validation for polling_url with Liquid variable resolution + $this->validatePollingUrl(); + + $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 + { + if ($this->data_strategy === 'polling' && !empty($this->polling_url)) { + try { + $resolvedUrl = $this->plugin->resolveLiquidVariables($this->polling_url); + + if (!filter_var($resolvedUrl, FILTER_VALIDATE_URL)) { + $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()); + } + } + } + + public function updateData(): void + { + if ($this->plugin->data_strategy === 'polling') { + try { + $this->plugin->updateDataPayload(); + + $this->data_payload = json_encode($this->plugin->data_payload, JSON_PRETTY_PRINT); + $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()); + } + } + } + + public function getAvailablePlugins() + { + return auth()->user()->plugins()->where('id', '!=', $this->plugin->id)->get(); + } + + public function getRequiredPluginCount(): int + { + if ($this->mashup_layout === 'full') { + return 1; + } + + return match ($this->mashup_layout) { + '1Lx1R', '1Tx1B' => 2, // Left-Right or Top-Bottom split + '1Lx2R', '2Lx1R', '2Tx1B', '1Tx2B' => 3, // Two on one side, one on other + '2x2' => 4, // Quadrant + default => 1, + }; + } + + public function addToPlaylist() + { + $this->validate([ + 'checked_devices' => 'required|array|min:1', + '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') { + // Create new playlist + $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]); + } + + // Add plugin to playlist + $maxOrder = $playlist->items()->max('order') ?? 0; + + if ($this->mashup_layout === 'full') { + $playlist->items()->create([ + 'plugin_id' => $this->plugin->id, + 'order' => $maxOrder + 1, + ]); + } else { + // Create mashup + $pluginIds = array_merge([$this->plugin->id], array_map('intval', $this->mashup_plugins)); + \App\Models\PlaylistItem::createMashup( + $playlist, + $this->mashup_layout, + $pluginIds, + $this->plugin->name . ' Mashup', + $maxOrder + 1 + ); + } + } + + $this->reset([ + 'checked_devices', + 'device_playlists', + 'device_playlist_names', + 'device_weekdays', + 'device_active_from', + 'device_active_until', + 'mashup_layout', + 'mashup_plugins' + ]); + 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 getConfigurationValue($key, $default = null) + { + return $this->configuration[$key] ?? $default; + } + + public function renderExample(string $example) + { + switch ($example) { + case 'layoutTitle': + $markup = $this->renderLayoutWithTitleBar(); + break; + case 'layout': + $markup = $this->renderLayoutBlank(); + break; + default: + $markup = '

Hello World!

'; + break; + } + $this->markup_code = $markup; + } + + public function renderLayoutWithTitleBar(): string + { + if ($this->markup_language === 'liquid') { + return << +
+ +
+
+ TRMNL BYOS +
+
+HTML; + } + + return << 'full']) + + + + + + +HTML; + } + + public function renderLayoutBlank(): string + { + if ($this->markup_language === 'liquid') { + return << +
+ +
+
+HTML; + } + + return << 'full']) + + + + + +HTML; + } + + public function renderPreview($size = 'full'): void + { + 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); + $this->dispatch('preview-updated', preview: $previewMarkup); + } catch (LiquidException $e) { + $this->dispatch('preview-error', message: $e->toLiquidErrorMessage()); + } catch (\Exception $e) { + $this->dispatch('preview-error', message: $e->getMessage()); + } + } + + 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); + $this->plugin->delete(); + $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(); + } + + // Laravel Livewire computed property: access with $this->parsed_urls + #[Computed] + private function parsedUrls() + { + if (!isset($this->polling_url)) { + return null; + } + + try { + return $this->plugin->resolveLiquidVariables($this->polling_url); + + } catch (\Exception $e) { + return 'PARSE_ERROR: ' . $e->getMessage(); + } + } + +} +?> + +
+
+
+

{{$plugin->name}} + Recipe +

+ + + + Preview + + + + + + Half-Horizontal + + + + + Half-Vertical + + + + + Quadrant + + + + + + + Add to Playlist + + + + + + + Recipe Settings + + + Duplicate Plugin + + Delete Plugin + + + + +
+ + +
+
+ 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 + + @if(count($checked_devices) > 0 && $this->hasAnyPlaylistSelected()) + +
+ + + + + + + + + + +
+ + @if($mashup_layout !== 'full') +
+
Mashup Slots
+
+
+
Main Plugin
+ +
+ @for($i = 0; $i < $this->getRequiredPluginCount() - 1; $i++) +
+
Plugin {{ $i + 2 }}:
+ + + @foreach($this->getAvailablePlugins() as $availablePlugin) + + @endforeach + +
+ @endfor +
+
+ @endif + @endif + +
+ + Add to Playlist +
+ +
+
+ + +
+ Delete {{ $plugin->name }}? +

This will remove this plugin from your + account.

+
+ +
+ + + Cancel + + Delete plugin +
+
+ + +
+ Preview {{ $plugin->name }} + + + @foreach($this->getDeviceModels() as $model) + + @endforeach + + +
+ +
+ +
+
+ + + + + +
+

Settings

+
+
+
+
+
+ +
+ + @php + $authorField = null; + if (isset($configuration_template['custom_fields'])) { + foreach ($configuration_template['custom_fields'] as $field) { + if ($field['field_type'] === 'author_bio') { + $authorField = $field; + break; + } + } + } + @endphp + + @if($authorField) +
+
+ {{ $authorField['description'] }} +
+ + @if(isset($authorField['github_url']) || isset($authorField['learn_more_url']) || isset($authorField['email_address'])) +
+ @if(isset($authorField['github_url'])) + @php + $githubUrl = $authorField['github_url']; + $githubUsername = null; + + // Extract username from various GitHub URL formats + if (preg_match('/github\.com\/([^\/\?]+)/', $githubUrl, $matches)) { + $githubUsername = $matches[1]; + } + @endphp + @if($githubUsername)@endif + @endif + @if(isset($authorField['learn_more_url'])) + + Learn More + + @endif + + @if(isset($authorField['github_url'])) + + + @endif + + @if(isset($authorField['email_address'])) + + + @endif +
+ @endif +
+ @endif + + @if(isset($configuration_template['custom_fields']) && !empty($configuration_template['custom_fields'])) + @if($plugin->hasMissingRequiredConfigurationFields()) + + @endif +
+ + Configuration Fields + +
+ @endif +
+ + + + + +
+ + @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 }} + + +
+ + + Fetch data now + +
+
+ +
+ + + + +
+ +
+ +
+ + @if($polling_verb === 'post') +
+ +
+ @endif +
+ +
+ @elseif($data_strategy === 'webhook') +
+ + +
+ @elseif($data_strategy === 'static') + Enter static JSON data in the Data Payload field. + @endif + +
+ Screen Settings +
+ + +
+
+ +
+ + Save +
+ +
+
+
+ Data Payload + @isset($this->data_payload_updated_at) + {{ $this->data_payload_updated_at?->diffForHumans() ?? 'Never' }} + @endisset +
+ + + @php + $textareaId = 'payload-' . uniqid(); + @endphp + + + +
+
+ +
+

Markup

+ @if($plugin->render_markup_view) +
+ Edit view + {{ $plugin->render_markup_view }} to update. +
+
+ + @php + $textareaId = 'code-view-' . uniqid(); + @endphp + + +
+ @else +
+
+ Template language + + + + +
+
+ Getting started + Responsive Layout with Title Bar + Responsive Layout +
+
+ @endif +
+ @if(!$plugin->render_markup_view) +
+
+ + @php + $textareaId = 'code-' . uniqid(); + @endphp + {{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }} + + +
+ +
+ + Save + +
+
+ @endif +
+
+ + + +@script + +@endscript diff --git a/resources/views/livewire/plugins/recipes/settings.blade.php b/resources/views/livewire/plugins/recipes/settings.blade.php new file mode 100644 index 0000000..8ae3d6f --- /dev/null +++ b/resources/views/livewire/plugins/recipes/settings.blade.php @@ -0,0 +1,104 @@ +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 new file mode 100644 index 0000000..cf9dcb7 --- /dev/null +++ b/resources/views/livewire/settings/preferences.blade.php @@ -0,0 +1,79 @@ +assign_new_device_id = Auth::user()->assign_new_device_id; + $this->timezone = Auth::user()->timezone ?? config('app.timezone'); + } + + public function updatePreferences(): void + { + $validated = $this->validate([ + 'assign_new_device_id' => [ + 'nullable', + Rule::exists('devices', 'id')->where(function ($query) { + $query->where('user_id', Auth::id()) + ->whereNull('mirror_device_id'); + }), + ], + 'timezone' => [ + 'nullable', + 'string', + Rule::in(timezone_identifiers_list()), + ], + ]); + + Auth::user()->update($validated); + + $this->dispatch('profile-updated'); + } +}; ?> + +
+ @include('partials.settings-heading') + + +
+ + + Select timezone... + @foreach(timezone_identifiers_list() as $tz) + {{ $tz }} + @endforeach + + + + None + @foreach(auth()->user()->devices->where('mirror_device_id', null) as $device) + + {{ $device->name }} ({{ $device->friendly_id }}) + + @endforeach + + +
+
+ {{ __('Save') }} +
+ + + {{ __('Saved.') }} + +
+
+ +
+
diff --git a/resources/views/livewire/settings/support.blade.php b/resources/views/livewire/settings/support.blade.php new file mode 100644 index 0000000..7241d72 --- /dev/null +++ b/resources/views/livewire/settings/support.blade.php @@ -0,0 +1,33 @@ +
+ @include('partials.settings-heading') + + + +
+
+ {{ __('GitHub Sponsors') }} + {{ __('Buy me a coffee') }} +
+
+ +
+ {{ __('Referral Code') }} + {{ __('Use the code to receive a $15 discount on your TRMNL device purchase.') }} + +
+ + {{ __('Referral link') }} +
+ +
+
+
diff --git a/resources/views/mail/battery-low.blade.php b/resources/views/mail/battery-low.blade.php new file mode 100644 index 0000000..34b3b63 --- /dev/null +++ b/resources/views/mail/battery-low.blade.php @@ -0,0 +1,7 @@ + +# Battery Low + +The battery of {{ $device->name }} is running below {{ $device->battery_percent }}%. Please charge your device soon. + +{{ config('app.name') }} + diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index 848e1b5..fa0f31a 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -1,7 +1,7 @@ -{{ $title ?? 'Laravel TRMNL Server' }} +{{ $title ?? 'TRMNL BYOS Laravel' }} diff --git a/resources/views/recipes/day-in-history.liquid b/resources/views/recipes/day-in-history.liquid new file mode 100644 index 0000000..c79f462 --- /dev/null +++ b/resources/views/recipes/day-in-history.liquid @@ -0,0 +1,57 @@ +
+
+
+
+
Events
+ {% for event in data.metadata.events %} +
+
+ +
+
+ {{ data.events[event].year }} + {{ data.events[event].text }} +
+
+ {% endfor %} +
+
+
Births
+ {% for birth in data.metadata.births %} +
+
+ +
+
+ {{ data.births[birth].year }} + {{ data.births[birth].text }} +
+
+ {% endfor %} +
Deaths
+ {% for death in data.metadata.deaths %} +
+
+ +
+
+ {{ data.deaths[death].year }} + {{ data.deaths[death].text }} +
+
+ {% endfor %} +
+
+
+
+ + This Day in History (Wikipedia) + + {{ data.metadata.current_date }} +
+
diff --git a/resources/views/recipes/holidays-ical.blade.php b/resources/views/recipes/holidays-ical.blade.php new file mode 100644 index 0000000..454709d --- /dev/null +++ b/resources/views/recipes/holidays-ical.blade.php @@ -0,0 +1,97 @@ +@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/home-assistant.blade.php b/resources/views/recipes/home-assistant.blade.php new file mode 100644 index 0000000..686b33a --- /dev/null +++ b/resources/views/recipes/home-assistant.blade.php @@ -0,0 +1,69 @@ +@php + $weatherEntity = collect($data)->first(function($entity) { + return $entity['entity_id'] === 'weather.forecast_home'; + }); +@endphp + +@props(['size' => 'full']) + + + @if($weatherEntity) + +
+
+ +
+
+
+
+
+ {{ $weatherEntity['attributes']['temperature'] }} + Temperature {{ $weatherEntity['attributes']['temperature_unit'] }} +
+
+
+
+
+
+
+ {{-- --}} +
+
+ {{ $weatherEntity['attributes']['wind_speed'] }} {{ $weatherEntity['attributes']['wind_speed_unit'] }} + Wind Speed +
+
+ +
+
+
+ {{-- --}} +
+
+ {{ $weatherEntity['attributes']['humidity'] }}% + Humidity +
+
+ +
+
+
+ {{-- --}} +
+
+ {{ Str::title($weatherEntity['state']) }} + Right Now +
+
+
+
+ + @else +

Weather forecast data not found.

+ @endif +
+ + +
diff --git a/resources/views/recipes/pollen-forecast-eu.liquid b/resources/views/recipes/pollen-forecast-eu.liquid new file mode 100644 index 0000000..e3c2ddc --- /dev/null +++ b/resources/views/recipes/pollen-forecast-eu.liquid @@ -0,0 +1,167 @@ + + + +
+
+ +
+
+
+
+ {{ data.current.birch_pollen }} + grains/mΒ³ + Birch +
+
+
+
+
+ {{ data.current.grass_pollen }} + grains/mΒ³ + Grass +
+
+
+
+
+ {{ data.current.alder_pollen }} + grains/mΒ³ + Alder +
+
+
+
+
+ {{ data.current.mugwort_pollen }} + grains/mΒ³ + Mugwort +
+
+
+
+
+ {{ data.current.ragweed_pollen }} + grains/mΒ³ + Ragweed +
+
+
+ + +
+
+ +
+ + + + Pollen Forecast Vienna + Data provided by: Open-Meteo.com +
+
+ + +
diff --git a/resources/views/recipes/sunrise-sunset.liquid b/resources/views/recipes/sunrise-sunset.liquid new file mode 100644 index 0000000..3ae8eef --- /dev/null +++ b/resources/views/recipes/sunrise-sunset.liquid @@ -0,0 +1,50 @@ +
+
+ +
+ +
+
+ Sunrise + + {{ data.today.sunrise }} + +
+
+ +
+
+ Sunset + + {{ data.today.sunset }} + +
+
+
+ {% if size == 'full' or size == 'half_vertical' %} + +
+ + + +
+
+ Tomorrow +
+
+ ↑ {{ data.tomorrow.sunrise }} +
+
+ ↓ {{ data.tomorrow.sunset }} +
+
+
+
+
+ {% endif %} +
+ +
+ Sunrise & Sunset +
+
diff --git a/resources/views/recipes/train-monitor.blade.php b/resources/views/recipes/train-monitor.blade.php new file mode 100644 index 0000000..72fdeaf --- /dev/null +++ b/resources/views/recipes/train-monitor.blade.php @@ -0,0 +1,58 @@ +@props(['size' => 'full']) + + + + + + + Abfahrt + + + Aktuell + + + Zug + + + Ziel + + + Steig + + + + + @foreach($data['data'] as $journey) + + + {{ $journey['departure_time_planned'] ?? '' }} + + @if($journey['cancelled'] ?? false) + + {{ $journey['status'] ?? '' }} + + @else + + {{ $journey['departure_time_est'] ?? '' }} + + @endif + + {{ $journey['train_number'] ?? '' }} + + + {{ $journey['destination_station'] ?? '' }} + + + {{ $journey['track'] ?? '' }} + + + @endforeach + + + + + diff --git a/resources/views/recipes/train.blade.php b/resources/views/recipes/train.blade.php new file mode 100644 index 0000000..6521c89 --- /dev/null +++ b/resources/views/recipes/train.blade.php @@ -0,0 +1,58 @@ +@props(['size' => 'full']) + + + + + + + Abfahrt + + + Aktuell + + + Zug + + + Ziel + + + Steig + + + + + @foreach($data['departures'] as $journey) + + + {{ \Carbon\Carbon::parse($journey['scheduledTime'])->setTimezone(config('app.timezone'))->format('H:i') }} + + @if($journey['isCancelled']) + + Ausfall + + @else + + {{ \Carbon\Carbon::parse($journey['time'])->setTimezone(config('app.timezone'))->format('H:i') }} + + @endif + + {{ $journey['train'] }} + + + {{ $journey['destination'] }} + + + {{ $journey['platform']}} + + + @endforeach + + + + + diff --git a/resources/views/recipes/weather.blade.php b/resources/views/recipes/weather.blade.php new file mode 100644 index 0000000..0d8045f --- /dev/null +++ b/resources/views/recipes/weather.blade.php @@ -0,0 +1,61 @@ +{{--@dump($data)--}} +@props(['size' => 'full']) + + +
+
+ +
+
+
+
+
+ {{Arr::get($data, 'properties.timeseries.0.data.instant.details.air_temperature', 'N/A')}} + Temperature +
+
+
+
+
+
+
+ {{-- --}} +
+
+ {{Arr::get($data, 'properties.timeseries.0.data.instant.details.wind_speed', 'N/A')}} + Wind Speed (km/h) +
+
+ +
+
+
+ {{-- --}} +
+
+ {{Arr::get($data, 'properties.timeseries.0.data.instant.details.relative_humidity', 'N/A')}}% + Humidity +
+
+ +
+
+
+ {{-- --}} +
+
+ {{Str::title(Arr::get($data, 'properties.timeseries.0.data.next_1_hours.summary.symbol_code', 'N/A'))}} + Right Now +
+
+
+
+ +
+ +
diff --git a/resources/views/recipes/zen.blade.php b/resources/views/recipes/zen.blade.php new file mode 100644 index 0000000..0ae920f --- /dev/null +++ b/resources/views/recipes/zen.blade.php @@ -0,0 +1,19 @@ +{{--@dump($data)--}} +@props(['size' => 'full']) + + + +
{{$data['data'][0]['a'] ?? ''}}
+ @if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant') +

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

+ @else +

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

+ @endif +
+
+ +
+ + Zen Quotes +
+
diff --git a/resources/views/trmnl-layouts/mashup.blade.php b/resources/views/trmnl-layouts/mashup.blade.php new file mode 100644 index 0000000..1d8321f --- /dev/null +++ b/resources/views/trmnl-layouts/mashup.blade.php @@ -0,0 +1,25 @@ +@props([ + 'mashupLayout' => '1Tx1B', + 'noBleed' => false, + 'darkMode' => false, + 'deviceVariant' => 'og', + 'deviceOrientation' => null, + 'colorDepth' => '1bit', + 'scaleLevel' => null, +]) + +@if(config('app.puppeteer_window_size_strategy') === 'v2') + + + {!! $slot !!} + + +@else + + + {!! $slot !!} + + +@endif diff --git a/resources/views/trmnl-layouts/single.blade.php b/resources/views/trmnl-layouts/single.blade.php new file mode 100644 index 0000000..c6d6499 --- /dev/null +++ b/resources/views/trmnl-layouts/single.blade.php @@ -0,0 +1,20 @@ +@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 diff --git a/resources/views/trmnl.blade.php b/resources/views/trmnl.blade.php index bcfd3b5..9f49685 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 new file mode 100644 index 0000000..b5e570f --- /dev/null +++ b/resources/views/vendor/trmnl/components/screen.blade.php @@ -0,0 +1,35 @@ +@props([ + 'noBleed' => false, + 'darkMode' => false, + 'deviceVariant' => 'og', + 'deviceOrientation' => null, + 'colorDepth' => '1bit', + 'scaleLevel' => null, +]) + + + + + + + + @if (config('trmnl-blade.framework_css_url')) + + @else + + @endif + @if (config('trmnl-blade.framework_js_url')) + + @else + + @endif + {{ $title ?? config('app.name') }} + + +
+ {{ $slot }} +
+ + diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index 25454cd..96fa464 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -1,6 +1,6 @@
- +
@if (Route::has('login')) @@ -31,4 +31,40 @@ @endif
+ @auth + @if(config('app.version')) + Version: {{ config('app.version') }} + + + @php + $response = Cache::remember('latest_release', 86400, function () { + try { + $response = Http::get('https://api.github.com/repos/usetrmnl/byos_laravel/releases/latest'); + if ($response->successful()) { + return $response->json(); + } + } catch (\Exception $e) { + Log::debug('Failed to fetch latest release: ' . $e->getMessage()); + } + return null; + }); + $latestVersion = Arr::get($response, 'tag_name'); + + if ($latestVersion && version_compare($latestVersion, config('app.version'), '>')) { + $newVersion = $latestVersion; + } + @endphp + + @if(isset($newVersion)) + + Update available + + There is a newer version {{ $newVersion }} available. Update to the latest version for the best experience. + Release notes + + + @endif + @endif + @endauth
diff --git a/routes/api.php b/routes/api.php index eabce1b..f3a31a1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,16 +2,23 @@ use App\Jobs\GenerateScreenJob; use App\Models\Device; +use App\Models\DeviceLog; +use App\Models\DeviceModel; +use App\Models\Plugin; use App\Models\User; +use App\Services\ImageGenerationService; +use App\Services\PluginImportService; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; Route::get('/display', function (Request $request) { - $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('mac_address', $mac_address) + $device = Device::where('mac_address', mb_strtoupper($mac_address ?? '')) ->where('api_key', $access_token) ->first(); @@ -22,12 +29,13 @@ Route::get('/display', function (Request $request) { if ($auto_assign_user) { // Create a new device and assign it to this user $device = Device::create([ - 'mac_address' => $mac_address, + 'mac_address' => mb_strtoupper($mac_address ?? ''), 'api_key' => $access_token, 'user_id' => $auto_assign_user->id, 'name' => "{$auto_assign_user->name}'s TRMNL", 'friendly_id' => Str::random(6), 'default_refresh_interval' => 900, + 'mirror_device_id' => $auto_assign_user->assign_new_device_id, ]); } else { return response()->json([ @@ -40,83 +48,229 @@ Route::get('/display', function (Request $request) { 'last_rssi_level' => $request->header('rssi'), 'last_battery_voltage' => $request->header('battery_voltage'), 'last_firmware_version' => $request->header('fw-version'), + 'last_refreshed_at' => now(), ]); - $image_uuid = $device->current_screen_image; - if (! $image_uuid) { - $image_path = 'images/setup-logo.bmp'; - $filename = 'setup-logo.bmp'; - } else { - $image_path = 'images/generated/'.$image_uuid.'.bmp'; - $filename = basename($image_path); + if ($request->hasHeader('battery-percent')) { + $batteryPercent = (int) $request->header('battery-percent'); + $batteryVoltage = $device->calculateVoltageFromPercent($batteryPercent); + $device->update([ + 'last_battery_voltage' => $batteryVoltage, + ]); } - return response()->json([ - 'status' => '0', + 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); + $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); + $refreshTimeOverride = $device->getSleepModeEndsInSeconds() ?? $device->default_refresh_interval; + } else { + // Get current screen image from a mirror device or continue if not available + if (! $image_uuid = $device->mirrorDevice?->current_screen_image) { + $refreshTimeOverride = null; + // Skip if cloud proxy is enabled for the device + if (! $device->proxy_cloud || $device->getNextPlaylistItem()) { + $playlistItem = $device->getNextPlaylistItem(); + + if ($playlistItem && ! $playlistItem->isMashup()) { + $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; + $plugin = $playlistItem->plugin; + + // Reset cache if Devices with different dimensions exist + ImageGenerationService::resetIfNotCacheable($plugin); + + // Check and update stale data if needed + if ($plugin->isDataStale() || $plugin->current_image === null) { + $plugin->updateDataPayload(); + try { + $markup = $plugin->render(device: $device); + + 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]); + } + } + + $plugin->refresh(); + + if ($plugin->current_image !== null) { + $playlistItem->update(['last_displayed_at' => now()]); + $device->update(['current_screen_image' => $plugin->current_image]); + } + } elseif ($playlistItem) { + $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; + + // Get all plugins for the mashup + $plugins = Plugin::whereIn('id', $playlistItem->getMashupPluginIds())->get(); + + foreach ($plugins as $plugin) { + // Reset cache if Devices with different dimensions exist + ImageGenerationService::resetIfNotCacheable($plugin); + if ($plugin->isDataStale() || $plugin->current_image === null) { + $plugin->updateDataPayload(); + } + } + + 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]); + } + + $device->refresh(); + + if ($device->current_screen_image !== null) { + $playlistItem->update(['last_displayed_at' => now()]); + } + } + } + + $device->refresh(); + $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); + } else { + // Determine image format based on device settings + $preferred_format = 'png'; // Default to PNG for newer firmware + + if (! $device->device_model_id) { + // No device model, use device's image_format setting + if (str_contains($device->image_format, 'bmp')) { + $preferred_format = 'bmp'; + } + // For 'auto' or unknown formats, fall back to firmware version logic + if (isset($device->last_firmware_version) + && version_compare($device->last_firmware_version, '1.5.2', '<') + && Storage::disk('public')->exists('images/generated/'.$image_uuid.'.bmp')) { + $preferred_format = 'bmp'; + } + } + + // Check if a preferred format exists, otherwise fall back + if (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.'.$preferred_format)) { + $image_path = 'images/generated/'.$image_uuid.'.'.$preferred_format; + } elseif (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.png')) { + $image_path = 'images/generated/'.$image_uuid.'.png'; + } else { + $image_path = 'images/generated/'.$image_uuid.'.bmp'; + } + $filename = basename($image_path); + } + } + + $response = [ + 'status' => 0, 'image_url' => url('storage/'.$image_path), 'filename' => $filename, - 'refresh_rate' => 900, + 'refresh_rate' => $refreshTimeOverride ?? $device->default_refresh_interval, 'reset_firmware' => false, - 'update_firmware' => false, - 'firmware_url' => null, - 'special_function' => 'sleep', - ]); + 'update_firmware' => $device->update_firmware, + 'firmware_url' => $device->firmware_url, + 'special_function' => $device->special_function ?? 'sleep', + ]; + + if (config('services.trmnl.image_url_timeout')) { + $response['image_url_timeout'] = config('services.trmnl.image_url_timeout'); + } + // If update_firmware is true, reset it after returning it, to avoid upgrade loop + if ($device->update_firmware) { + $device->resetUpdateFirmwareFlag(); + } + + return response()->json($response); }); Route::get('/setup', function (Request $request) { $mac_address = $request->header('id'); + $model_name = $request->header('model-id'); if (! $mac_address) { return response()->json([ - 'status' => '404', + 'status' => 404, 'message' => 'MAC Address not registered', ], 404); } - $device = Device::where('mac_address', $mac_address)->first(); + $device = Device::where('mac_address', mb_strtoupper($mac_address))->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) { + // Check if device model exists by name + $device_model = null; + if ($model_name) { + $device_model = DeviceModel::where('name', $model_name)->first(); + } + // Create a new device and assign it to this user $device = Device::create([ - 'mac_address' => $mac_address, + 'mac_address' => mb_strtoupper($mac_address), 'api_key' => Str::random(22), 'user_id' => $auto_assign_user->id, 'name' => "{$auto_assign_user->name}'s TRMNL", 'friendly_id' => Str::random(6), 'default_refresh_interval' => 900, + 'mirror_device_id' => $auto_assign_user->assign_new_device_id, + 'device_model_id' => $device_model?->id, ]); } else { return response()->json([ - 'status' => '404', + 'status' => 404, 'message' => 'MAC Address not registered or invalid access token', ], 404); } } return response()->json([ - 'status' => '200', + 'status' => 200, 'api_key' => $device->api_key, 'friendly_id' => $device->friendly_id, - 'image_url' => url('storage/images/setup-logo.png'), + 'image_url' => url('storage/'.ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo')), 'message' => 'Welcome to TRMNL BYOS', ]); }); Route::post('/log', function (Request $request) { - $mac_address = $request->header('id'); + // $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('mac_address', $mac_address) - ->where('api_key', $access_token) + $device = Device::where('api_key', $access_token) // where('mac_address', $mac_address) ->first(); if (! $device) { return response()->json([ - 'status' => '404', + 'status' => 404, 'message' => 'Device not found or invalid access token', ], 404); } @@ -125,13 +279,27 @@ Route::post('/log', function (Request $request) { 'last_log_request' => $request->json()->all(), ]); - $logs = $request->json('log.logs_array', []); + $logs = []; + // Revised format: {"logs": [...]} + if ($request->has('logs')) { + $logs = $request->json('logs', []); + } + // Fall back to old format: {"log": {"logs_array": [...]}} + elseif ($request->has('log.logs_array')) { + $logs = $request->json('log.logs_array', []); + } + foreach ($logs as $log) { - \Log::info('Device Log', $log); + Log::info('Device Log', $log); + DeviceLog::create([ + 'device_id' => $device->id, + 'device_timestamp' => $log['creation_timestamp'] ?? now(), + 'log_entry' => $log, + ]); } return response()->json([ - 'status' => '0', + 'status' => 200, ]); }); @@ -139,6 +307,37 @@ Route::get('/user', function (Request $request) { return $request->user(); })->middleware('auth:sanctum'); +Route::get('/devices', function (Request $request) { + $devices = $request->user()->devices()->get([ + 'id', + 'name', + 'friendly_id', + 'mac_address', + 'last_battery_voltage as battery_voltage', + 'last_rssi_level as rssi', + ]); + + return response()->json([ + 'data' => $devices, + ]); +})->middleware('auth:sanctum'); + +Route::get('/device-models', function (Request $request) { + $deviceModels = DeviceModel::get([ + 'id', + 'name', + 'label', + 'description', + 'width', + 'height', + 'bit_depth', + ]); + + return response()->json([ + 'data' => $deviceModels, + ]); +})->middleware('auth:sanctum'); + Route::post('/display/update', function (Request $request) { $request->validate([ 'device_id' => 'required|exists:devices,id', @@ -150,7 +349,7 @@ Route::post('/display/update', function (Request $request) { $view = Blade::render($request['markup']); - GenerateScreenJob::dispatchSync($deviceId, $view); + GenerateScreenJob::dispatchSync($deviceId, null, $view); response()->json([ 'message' => 'success', @@ -158,3 +357,411 @@ Route::post('/display/update', function (Request $request) { }) ->name('display.update') ->middleware('auth:sanctum', 'ability:update-screen'); + +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 ?? '')) + ->where('api_key', $access_token) + ->first(); + + if (! $device) { + return response()->json([ + 'message' => 'MAC Address not registered or invalid access token', + ], 404); + } + + $request->validate([ + 'image' => 'array|required', + 'image.content' => 'string|required', + 'image.file_name' => 'string', + ]); + $content = $request['image']['content']; + + $view = Blade::render($content); + GenerateScreenJob::dispatchSync($device->id, null, $view); + + return response()->json([ + 'message' => 'success', + ]); +})->name('screens.update'); + +Route::get('/display/status', function (Request $request) { + $request->validate([ + 'device_id' => 'required|exists:devices,id', + ]); + + $deviceId = $request['device_id']; + abort_unless($request->user()->devices->contains($deviceId), 403); + + return response()->json( + Device::find($deviceId)->only([ + 'id', + 'mac_address', + 'name', + 'friendly_id', + 'last_rssi_level', + 'last_battery_voltage', + 'last_firmware_version', + 'battery_percent', + 'wifi_strength', + 'current_screen_image', + 'default_refresh_interval', + 'sleep_mode_enabled', + 'sleep_mode_from', + 'sleep_mode_to', + 'special_function', + 'pause_until', + 'updated_at', + ]), + ); +}) + ->name('display.status') + ->middleware('auth:sanctum'); + +Route::post('/display/status', function (Request $request) { + $request->validate([ + 'device_id' => 'required|exists:devices,id', + 'name' => 'string|max:255', + 'default_refresh_interval' => 'integer|min:1', + 'sleep_mode_enabled' => 'boolean', + 'sleep_mode_from' => 'nullable|date_format:H:i', + 'sleep_mode_to' => 'nullable|date_format:H:i', + 'pause_until' => 'nullable|date|after:now', + ]); + + $deviceId = $request['device_id']; + abort_unless($request->user()->devices->contains($deviceId), 403); + + $fieldsToUpdate = $request->only(['name', 'default_refresh_interval', 'sleep_mode_enabled', 'sleep_mode_from', 'sleep_mode_to', 'pause_until']); + Device::find($deviceId)->update($fieldsToUpdate); + + return response()->json( + Device::find($deviceId)->only([ + 'id', + 'mac_address', + 'name', + 'friendly_id', + 'last_rssi_level', + 'last_battery_voltage', + 'last_firmware_version', + 'battery_percent', + 'wifi_strength', + 'current_screen_image', + 'default_refresh_interval', + 'sleep_mode_enabled', + 'sleep_mode_from', + 'sleep_mode_to', + 'special_function', + 'pause_until', + 'updated_at', + ]), + ); +}) + ->name('display.status.post') + ->middleware('auth:sanctum'); + +Route::get('/current_screen', function (Request $request) { + $access_token = $request->header('access-token'); + $device = Device::where('api_key', $access_token)->first(); + + if (! $device) { + return response()->json([ + 'status' => 404, + 'message' => 'Device not found or invalid access token', + ], 404); + } + + $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); + } else { + // Determine image format based on device settings + $preferred_format = 'png'; // Default to PNG for newer firmware + + if (! $device->device_model_id) { + // No device model, use device's image_format setting + if (str_contains($device->image_format, 'bmp')) { + $preferred_format = 'bmp'; + } + // For 'auto' or unknown formats, fall back to firmware version logic + if (isset($device->last_firmware_version) + && version_compare($device->last_firmware_version, '1.5.2', '<') + && Storage::disk('public')->exists('images/generated/'.$image_uuid.'.bmp')) { + $preferred_format = 'bmp'; + } + } + + // Check if preferred format exists, otherwise fall back + if (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.'.$preferred_format)) { + $image_path = 'images/generated/'.$image_uuid.'.'.$preferred_format; + } elseif (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.png')) { + $image_path = 'images/generated/'.$image_uuid.'.png'; + } else { + $image_path = 'images/generated/'.$image_uuid.'.bmp'; + } + $filename = basename($image_path); + } + + $response = [ + 'status' => 200, + 'image_url' => url('storage/'.$image_path), + 'filename' => $filename, + 'refresh_rate' => $refreshTimeOverride ?? $device->default_refresh_interval, + 'reset_firmware' => false, + 'update_firmware' => false, + 'firmware_url' => $device->firmware_url, + 'special_function' => $device->special_function ?? 'sleep', + ]; + + if (config('services.trmnl.image_url_timeout')) { + $response['image_url_timeout'] = config('services.trmnl.image_url_timeout'); + } + + return response()->json($response); +}); + +Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) { + $plugin = Plugin::where('uuid', $plugin_uuid)->firstOrFail(); + + // Check if plugin uses webhook strategy + if ($plugin->data_strategy !== 'webhook') { + return response()->json(['error' => 'Plugin does not use webhook strategy'], 400); + } + + $request = request(); + if (! $request->has('merge_variables')) { + return response()->json(['error' => 'Request must contain merge_variables key'], 400); + } + + $plugin->update([ + 'data_payload' => $request->input('merge_variables'), + 'data_payload_updated_at' => now(), + ]); + + 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([ + 'message' => 'trmnlp_id is required', + ], 400); + } + + // Find the plugin by trmnlp_id and ensure it belongs to the authenticated user + $plugin = Plugin::where('trmnlp_id', $trmnlp_id) + ->where('user_id', auth()->user()->id) + ->firstOrFail(); + + // Use the export service to create the ZIP file + /** @var App\Services\PluginExportService $exporter */ + $exporter = app(App\Services\PluginExportService::class); + + return $exporter->exportToZip($plugin, auth()->user()); +})->middleware('auth:sanctum'); + +Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) { + if (! $trmnlp_id) { + return response()->json([ + 'message' => 'trmnl_id is required', + ]); + } + + $validated = $request->validate([ + 'file' => 'required|file|mimes:zip', + ]); + + /** @var Illuminate\Http\UploadedFile $file */ + $file = $request->file('file'); + // Apply archive to existing plugin using the import service + /** @var PluginImportService $importer */ + $importer = app(PluginImportService::class); + $plugin = $importer->importFromZip($file, auth()->user()); + + return response()->json([ + 'message' => 'Plugin settings archive processed successfully', + 'data' => [ + 'settings_yaml' => $plugin['trmnlp_yaml'], + ], + ]); +})->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/auth.php b/routes/auth.php index 5647405..49b2173 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,5 +1,6 @@ group(function () { Volt::route('reset-password/{token}', 'auth.reset-password') ->name('password.reset'); + // OIDC authentication routes + Route::get('auth/oidc/redirect', [OidcController::class, 'redirect']) + ->name('auth.oidc.redirect'); + + Route::get('auth/oidc/callback', [OidcController::class, 'callback']) + ->name('auth.oidc.callback'); + }); Route::middleware('auth')->group(function () { diff --git a/routes/console.php b/routes/console.php index d5cb361..24ea529 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,18 @@ cron(sprintf('*/%s * * * *', intval(config('services.trmnl.proxy_refresh_minutes', 15)))); +Schedule::job(FetchProxyCloudResponses::class, [])->cron( + config('services.trmnl.proxy_refresh_cron') ? config('services.trmnl.proxy_refresh_cron') : + sprintf('*/%s * * * *', (int) (config('services.trmnl.proxy_refresh_minutes', 15))) +); + +Schedule::job(FirmwarePollJob::class)->daily(); +Schedule::job(CleanupDeviceLogsJob::class)->daily(); +Schedule::job(FetchDeviceModelsJob::class)->weekly(); +Schedule::job(NotifyDeviceBatteryLowJob::class)->dailyAt('10:00'); diff --git a/routes/web.php b/routes/web.php index 355b892..b3069bd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ name('home'); Route::middleware(['auth'])->group(function () { - Route::redirect('settings', 'settings/profile'); + Route::redirect('settings', 'settings/preferences'); + Volt::route('settings/preferences', 'settings.preferences')->name('settings.preferences'); Volt::route('settings/profile', 'settings.profile')->name('settings.profile'); Volt::route('settings/password', 'settings.password')->name('settings.password'); Volt::route('settings/appearance', 'settings.appearance')->name('settings.appearance'); + Volt::route('settings/support', 'settings.support')->name('settings.support'); Volt::route('/dashboard', 'device-dashboard')->name('dashboard'); Volt::route('/devices', 'devices.manage')->name('devices'); Volt::route('/devices/{device}/configure', 'devices.configure')->name('devices.configure'); + 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) { + $plugin = Plugin::query() + ->where('user_id', $request->user()->id) + ->where('trmnlp_id', $trmnlp_id)->firstOrFail(); + + return redirect()->route('plugins.recipe', ['plugin' => $plugin]); + }); }); require __DIR__.'/auth.php'; diff --git a/screenshots/README_byos-screenshot2.png b/screenshots/README_byos-screenshot2.png deleted file mode 100644 index e06257e..0000000 Binary files a/screenshots/README_byos-screenshot2.png and /dev/null differ diff --git a/screenshots/README_byos-screenshot3.png b/screenshots/README_byos-screenshot3.png deleted file mode 100644 index 4012305..0000000 Binary files a/screenshots/README_byos-screenshot3.png and /dev/null differ diff --git a/screenshots/README_byos-screenshot4.png b/screenshots/README_byos-screenshot4.png deleted file mode 100644 index fdaf074..0000000 Binary files a/screenshots/README_byos-screenshot4.png and /dev/null differ diff --git a/screenshots/README_byos-screenshot5.png b/screenshots/README_byos-screenshot5.png deleted file mode 100644 index 141e5e9..0000000 Binary files a/screenshots/README_byos-screenshot5.png and /dev/null differ diff --git a/screenshots/README_byos-screenshot6.png b/screenshots/README_byos-screenshot6.png deleted file mode 100644 index 9fcc8de..0000000 Binary files a/screenshots/README_byos-screenshot6.png and /dev/null differ diff --git a/screenshots/SCREENSHOTS.md b/screenshots/SCREENSHOTS.md index 76e9661..cd1119f 100644 --- a/screenshots/SCREENSHOTS.md +++ b/screenshots/SCREENSHOTS.md @@ -1,12 +1,19 @@ ## Sceenshots +README_byos-screenshot2 -![Screenshot](README_byos-screenshot2.png) +README_byos-screenshot3 -![Screenshot](README_byos-screenshot3.png) +README_byos-screenshot4 -![Screenshot](README_byos-screenshot4.png) +README_byos-screenshot5 -![Screenshot](README_byos-screenshot5.png) +README_byos-screenshot6 -![Screenshot](README_byos-screenshot6.png) +README_byos-screenshot7 + +README_byos-screenshot8 + +README_byos-screenshot9 + +README_byos-screenshot10 diff --git a/storage/app/.gitignore b/storage/app/.gitignore deleted file mode 100644 index fedb287..0000000 --- a/storage/app/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!private/ -!public/ -!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/firmwares/.gitignore similarity index 60% rename from storage/app/public/.gitignore rename to storage/app/public/firmwares/.gitignore index 19a4b22..d6b7ef3 100644 --- a/storage/app/public/.gitignore +++ b/storage/app/public/firmwares/.gitignore @@ -1,3 +1,2 @@ * -!images/ !.gitignore diff --git a/storage/app/public/images/default-screens/.gitkeep b/storage/app/public/images/default-screens/.gitkeep new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..3734da1 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_1024_768_8_90.png 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 new file mode 100644 index 0000000..17dcf60 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_1200_820_3_0.png 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 new file mode 100644 index 0000000..71ecd65 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_1400_840_8_90.png 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 new file mode 100644 index 0000000..a350061 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_1440_1080_4_90.png 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 new file mode 100644 index 0000000..ec22fc8 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_1448_1072_8_90.png 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 new file mode 100644 index 0000000..f080990 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_1600_1200_1_0.png 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 new file mode 100644 index 0000000..c3099d5 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_1680_1264_8_90.png 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 new file mode 100644 index 0000000..5894ab8 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_1872_1404_8_90.png 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 new file mode 100644 index 0000000..1b0d150 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_800_480_1_0.png 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 new file mode 100644 index 0000000..031e369 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_800_480_2_0.png 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 new file mode 100644 index 0000000..61061e7 Binary files /dev/null and b/storage/app/public/images/default-screens/setup-logo_800_600_8_90.png 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 new file mode 100644 index 0000000..f8763f3 Binary files /dev/null and b/storage/app/public/images/default-screens/sleep_1024_768_8_90.png 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 new file mode 100644 index 0000000..287ec0a Binary files /dev/null and b/storage/app/public/images/default-screens/sleep_1200_820_3_0.png 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 new file mode 100644 index 0000000..2f2166e Binary files /dev/null and b/storage/app/public/images/default-screens/sleep_1400_840_8_90.png 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 new file mode 100644 index 0000000..5846cad Binary files /dev/null and b/storage/app/public/images/default-screens/sleep_1440_1080_4_90.png 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 new file mode 100644 index 0000000..a08c008 Binary files /dev/null and b/storage/app/public/images/default-screens/sleep_1448_1072_8_90.png 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 new file mode 100644 index 0000000..060da4e Binary files /dev/null and b/storage/app/public/images/default-screens/sleep_1600_1200_1_0.png 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 new file mode 100644 index 0000000..6509a44 Binary files /dev/null and b/storage/app/public/images/default-screens/sleep_1680_1264_8_90.png 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 new file mode 100644 index 0000000..792d964 Binary files /dev/null and b/storage/app/public/images/default-screens/sleep_1872_1404_8_90.png 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 new file mode 100644 index 0000000..f066974 Binary files /dev/null and b/storage/app/public/images/default-screens/sleep_800_600_8_90.png differ diff --git a/storage/app/public/images/setup-logo.png b/storage/app/public/images/setup-logo.png new file mode 100644 index 0000000..1fc342c Binary files /dev/null and b/storage/app/public/images/setup-logo.png differ diff --git a/storage/app/public/images/sleep.bmp b/storage/app/public/images/sleep.bmp new file mode 100644 index 0000000..a55af63 Binary files /dev/null and b/storage/app/public/images/sleep.bmp differ diff --git a/storage/app/public/images/sleep.png b/storage/app/public/images/sleep.png new file mode 100644 index 0000000..49bdabf Binary files /dev/null and b/storage/app/public/images/sleep.png differ diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index 62b8364..2925a5e 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -1,17 +1,27 @@ makeDirectory('/images/generated'); }); -test('device can fetch display data with valid credentials', function () { +test('device can fetch display data with valid credentials', function (): void { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -43,7 +53,49 @@ test('device can fetch display data with valid credentials', function () { ->last_firmware_version->toBe('1.0.0'); }); -test('new device is auto-assigned to user with auto-assign enabled', function () { +test('display endpoint includes image_url_timeout when configured', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + ]); + + config(['services.trmnl.image_url_timeout' => 300]); + + $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() + ->assertJson([ + 'image_url_timeout' => 300, + ]); +}); + +test('display endpoint omits image_url_timeout when not configured', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + ]); + + config(['services.trmnl.image_url_timeout' => 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() + ->assertJsonMissing(['image_url_timeout']); +}); + +test('new device is auto-assigned to user with auto-assign enabled', function (): void { $user = User::factory()->create(['assign_new_devices' => true]); $response = $this->withHeaders([ @@ -63,7 +115,46 @@ test('new device is auto-assigned to user with auto-assign enabled', function () ->api_key->toBe('new-device-key'); }); -test('device setup endpoint returns correct data', function () { +test('new device is auto-assigned and mirrors specified device', function (): void { + // Create a source device that will be mirrored + $sourceDevice = Device::factory()->create([ + 'mac_address' => 'AA:BB:CC:DD:EE:FF', + 'api_key' => 'source-api-key', + 'current_screen_image' => 'source-image', + ]); + + // Create user with auto-assign enabled and mirror device set + $user = User::factory()->create([ + 'assign_new_devices' => true, + 'assign_new_device_id' => $sourceDevice->id, + ]); + + // Make request from new device + $response = $this->withHeaders([ + 'id' => '00:11:22:33:44:55', + 'access-token' => 'new-device-key', + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk(); + + // Verify the new device was created and mirrors the source device + $newDevice = Device::where('mac_address', '00:11:22:33:44:55')->first(); + expect($newDevice) + ->not->toBeNull() + ->user_id->toBe($user->id) + ->api_key->toBe('new-device-key') + ->mirror_device_id->toBe($sourceDevice->id); + + // Verify the response contains the source device's image + $response->assertJson([ + 'filename' => 'source-image.bmp', + ]); +}); + +test('device setup endpoint returns correct data', function (): void { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -82,7 +173,7 @@ test('device setup endpoint returns correct data', function () { ]); }); -test('device can submit logs', function () { +test('device can submit logs', function (): void { $device = Device::factory()->create([ 'mac_address' => '00:11:22:33:44:55', 'api_key' => 'test-api-key', @@ -91,7 +182,7 @@ test('device can submit logs', function () { $logData = [ 'log' => [ 'logs_array' => [ - ['message' => 'Test log message', 'level' => 'info'], + ['log_message' => 'Test log message', 'level' => 'info'], ], ], ]; @@ -102,10 +193,38 @@ test('device can submit logs', function () { ])->postJson('/api/log', $logData); $response->assertOk() - ->assertJson(['status' => '0']); + ->assertJson(['status' => '200']); expect($device->fresh()->last_log_request) ->toBe($logData); + + expect($device->logs()->count())->toBe(1); +}); + +test('device can submit logs in revised format', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + ]); + + $logData = [ + 'logs' => [ + ['message' => 'Test log message', 'level' => 'info'], + ], + ]; + + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + ])->postJson('/api/log', $logData); + + $response->assertOk() + ->assertJson(['status' => '200']); + + expect($device->fresh()->last_log_request) + ->toBe($logData); + + expect($device->logs()->count())->toBe(1); }); // test('authenticated user can update device display', function () { @@ -122,7 +241,7 @@ test('device can submit logs', function () { // $response->assertOk(); // }); -test('user cannot update display for devices they do not own', function () { +test('user cannot update display for devices they do not own', function (): void { $user = User::factory()->create(); $otherUser = User::factory()->create(); $device = Device::factory()->create(['user_id' => $otherUser->id]); @@ -137,7 +256,7 @@ test('user cannot update display for devices they do not own', function () { $response->assertForbidden(); }); -test('invalid device credentials return error', function () { +test('invalid device credentials return error', function (): void { $response = $this->withHeaders([ 'id' => 'invalid-mac', 'access-token' => 'invalid-token', @@ -147,7 +266,7 @@ test('invalid device credentials return error', function () { ->assertJson(['message' => 'MAC Address not registered or invalid access token']); }); -test('log endpoint requires valid device credentials', function () { +test('log endpoint requires valid device credentials', function (): void { $response = $this->withHeaders([ 'id' => 'invalid-mac', 'access-token' => 'invalid-token', @@ -156,3 +275,912 @@ test('log endpoint requires valid device credentials', function () { $response->assertNotFound() ->assertJson(['message' => 'Device not found or invalid access token']); }); + +test('update_firmware flag is only returned once', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'proxy_cloud_response' => [ + 'update_firmware' => true, + 'firmware_url' => 'https://example.com/firmware.bin', + ], + ]); + + // First request should return update_firmware as true + $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() + ->assertJson([ + 'update_firmware' => true, + 'firmware_url' => 'https://example.com/firmware.bin', + ]); + + // Second request should return update_firmware as false + $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() + ->assertJson([ + 'update_firmware' => false, + 'firmware_url' => 'https://example.com/firmware.bin', + ]); + + // Verify the proxy_cloud_response was updated + $device->refresh(); + expect($device->proxy_cloud_response['update_firmware'])->toBeFalse(); +}); + +test('authenticated user can fetch device status', function (): void { + $user = User::factory()->create(); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'mac_address' => '00:11:22:33:44:55', + 'name' => 'Test Device', + 'friendly_id' => 'test-device', + 'last_rssi_level' => -70, + 'last_battery_voltage' => 3.8, + 'last_firmware_version' => '1.0.0', + 'current_screen_image' => 'test-image', + 'default_refresh_interval' => 900, + ]); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/display/status?device_id='.$device->id); + + $response->assertOk() + ->assertJson([ + 'id' => $device->id, + 'mac_address' => '00:11:22:33:44:55', + 'name' => 'Test Device', + 'friendly_id' => 'test-device', + 'last_rssi_level' => -70, + 'last_battery_voltage' => 3.8, + 'last_firmware_version' => '1.0.0', + 'battery_percent' => 67, + 'wifi_strength' => 2, + 'current_screen_image' => 'test-image', + 'default_refresh_interval' => 900, + ]); +}); + +test('user cannot fetch status for devices they do not own', function (): void { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $device = Device::factory()->create(['user_id' => $otherUser->id]); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/display/status?device_id='.$device->id); + + $response->assertForbidden(); +}); + +test('display status endpoint requires device_id parameter', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/display/status'); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['device_id']); +}); + +test('display status endpoint requires valid device_id', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/display/status?device_id=999'); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['device_id']); +}); + +test('device can mirror another device', function (): void { + // Create source device with a playlist and image + $sourceDevice = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'source-api-key', + 'current_screen_image' => 'source-image', + ]); + + // Create mirroring device + $mirrorDevice = Device::factory()->create([ + 'mac_address' => 'AA:BB:CC:DD:EE:FF', + 'api_key' => 'mirror-api-key', + 'mirror_device_id' => $sourceDevice->id, + ]); + + // Make request from mirror device + $response = $this->withHeaders([ + 'id' => $mirrorDevice->mac_address, + 'access-token' => $mirrorDevice->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk() + ->assertJson([ + 'status' => '0', + 'filename' => 'source-image.bmp', + 'refresh_rate' => 900, + 'reset_firmware' => false, + 'update_firmware' => false, + 'firmware_url' => null, + 'special_function' => 'sleep', + ]); + + // Verify mirror device stats were updated + expect($mirrorDevice->fresh()) + ->last_rssi_level->toBe(-70) + ->last_battery_voltage->toBe(3.8) + ->last_firmware_version->toBe('1.0.0'); +}); + +test('device can fetch current screen data', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'current_screen_image' => 'test-image', + ]); + + $response = $this->withHeaders([ + 'access-token' => $device->api_key, + ])->get('/api/current_screen'); + + $response->assertOk() + ->assertJson([ + 'status' => 200, + 'filename' => 'test-image.bmp', + 'refresh_rate' => 900, + 'reset_firmware' => false, + 'update_firmware' => false, + 'firmware_url' => null, + 'special_function' => 'sleep', + ]); +}); + +test('current_screen endpoint requires valid device credentials', function (): void { + $response = $this->withHeaders([ + 'access-token' => 'invalid-token', + ])->get('/api/current_screen'); + + $response->assertNotFound() + ->assertJson(['message' => 'Device not found or invalid access token']); +}); + +test('authenticated user can fetch their devices', function (): void { + $user = User::factory()->create(); + $devices = Device::factory()->count(2)->create([ + 'user_id' => $user->id, + 'last_battery_voltage' => 3.72, + 'last_rssi_level' => -63, + ]); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/devices'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'name', + 'friendly_id', + 'mac_address', + 'battery_voltage', + 'rssi', + ], + ], + ]) + ->assertJsonCount(2, 'data'); + + // Verify the first device's data + $response->assertJson([ + 'data' => [ + [ + 'id' => $devices[0]->id, + 'name' => $devices[0]->name, + 'friendly_id' => $devices[0]->friendly_id, + 'mac_address' => $devices[0]->mac_address, + 'battery_voltage' => 3.72, + 'rssi' => -63, + ], + ], + ]); +}); + +test('plugin caches image until data is stale', function (): void { + // Create source device with a playlist + $device = Device::factory()->create([ + 'mac_address' => '55:11:22:33:44:55', + 'api_key' => 'source-api-key', + 'proxy_cloud' => false, + ]); + + $plugin = Plugin::factory()->create([ + 'name' => 'Zen Quotes', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'data_strategy' => 'polling', + 'polling_verb' => 'get', + 'render_markup_view' => 'trmnl', + 'is_native' => false, + 'data_payload_updated_at' => null, + ]); + + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'update_test', + '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, + ]); + + // initial request, generates the image + $firstResponse = $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'); + + $firstResponse->assertOk(); + expect($firstResponse['filename'])->not->toBe('setup-logo.bmp'); + + // second request after 15 seconds, shouldn't generate a new image + $plugin->update(['data_payload_updated_at' => now()->addSeconds(-15)]); + $secondResponse = $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'); + + expect($secondResponse['filename']) + ->toBe($firstResponse['filename']); + + // third request after 75 seconds, should generate a new image + $plugin->update(['data_payload_updated_at' => now()->addSeconds(-75)]); + $thirdResponse = $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'); + + expect($thirdResponse['filename']) + ->not->toBe($firstResponse['filename']); +}); + +test('plugins in playlist are rendered in order', function (): void { + // Create source device with a playlist + $device = Device::factory()->create([ + 'mac_address' => '55:11:22:33:44:55', + 'api_key' => 'source-api-key', + 'proxy_cloud' => true, + ]); + + // Create two plugins + $firstPlugin = Plugin::factory()->create([ + 'name' => 'First Plugin', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'data_strategy' => 'polling', + 'polling_verb' => 'get', + 'render_markup_view' => 'trmnl', + 'is_native' => false, + 'data_payload_updated_at' => null, + ]); + + $secondPlugin = Plugin::factory()->create([ + 'name' => 'Second Plugin', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'data_strategy' => 'polling', + 'polling_verb' => 'get', + 'render_markup_view' => 'trmnl', + 'is_native' => false, + 'data_payload_updated_at' => null, + ]); + + // Create playlist + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'Two Plugins Test', + 'is_active' => true, + 'weekdays' => null, + 'active_from' => null, + 'active_until' => null, + ]); + + // Add plugins to playlist in specific order + PlaylistItem::factory()->create([ + 'playlist_id' => $playlist->id, + 'plugin_id' => $firstPlugin->id, + 'order' => 1, + 'is_active' => true, + 'last_displayed_at' => null, + ]); + + PlaylistItem::factory()->create([ + 'playlist_id' => $playlist->id, + 'plugin_id' => $secondPlugin->id, + 'order' => 2, + 'is_active' => true, + 'last_displayed_at' => null, + ]); + + // First request should show the first plugin + $firstResponse = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + ])->get('/api/display'); + + $firstResponse->assertOk(); + $firstImageFilename = $firstResponse['filename']; + expect($firstImageFilename)->not->toBe('setup-logo.bmp'); + + // Get the first plugin's playlist item and verify it was marked as displayed + $firstPluginItem = PlaylistItem::where('plugin_id', $firstPlugin->id)->first(); + expect($firstPluginItem->last_displayed_at)->not->toBeNull(); + + // Second request should show the second plugin + $secondResponse = $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'); + + $secondResponse->assertOk(); + expect($secondResponse['filename']) + ->not->toBe($firstImageFilename) + ->not->toBe('setup-logo.bmp'); + + // Get the second plugin's playlist item and verify it was marked as displayed + $secondPluginItem = PlaylistItem::where('plugin_id', $secondPlugin->id)->first(); + expect($secondPluginItem->last_displayed_at)->not->toBeNull(); + + // Third request should show the first plugin again + $thirdResponse = $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'); + + $thirdResponse->assertOk(); + expect($thirdResponse['filename']) + ->not->toBe($secondResponse['filename']); +}); + +test('display endpoint updates last_refreshed_at timestamp', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + ]); + + $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(); + + $device->refresh(); + expect($device->last_refreshed_at)->not->toBeNull() + ->and($device->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2); +}); + +test('display endpoint updates last_refreshed_at timestamp for mirrored devices', function (): void { + // Create source device + $sourceDevice = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'source-api-key', + ]); + + // Create mirroring device + $mirrorDevice = Device::factory()->create([ + 'mac_address' => 'AA:BB:CC:DD:EE:FF', + 'api_key' => 'mirror-api-key', + 'mirror_device_id' => $sourceDevice->id, + ]); + + $response = $this->withHeaders([ + 'id' => $mirrorDevice->mac_address, + 'access-token' => $mirrorDevice->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk(); + + $mirrorDevice->refresh(); + expect($mirrorDevice->last_refreshed_at)->not->toBeNull() + ->and($mirrorDevice->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2); +}); + +test('display endpoint handles mashup playlist items correctly', function (): void { + // Create a device + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'proxy_cloud' => false, + ]); + + // Create a playlist + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'update_test', + 'is_active' => true, + 'weekdays' => null, + 'active_from' => null, + 'active_until' => null, + ]); + + // Create three plugins for the mashup + $plugin1 = Plugin::factory()->create([ + 'name' => 'Plugin 1', + 'data_strategy' => 'webhook', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'render_markup_view' => 'trmnl', + ]); + + $plugin2 = Plugin::factory()->create([ + 'name' => 'Plugin 2', + 'data_strategy' => 'webhook', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'render_markup_view' => 'trmnl', + ]); + + // Create a mashup playlist item with a 2Lx1R layout (2 plugins on left, 1 on right) + $playlistItem = PlaylistItem::createMashup( + $playlist, + '1Lx1R', + [$plugin1->id, $plugin2->id], + 'Test Mashup', + 1 + ); + + // Make request to display endpoint + $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 the playlist item was marked as displayed + $playlistItem->refresh(); + expect($playlistItem->last_displayed_at)->not->toBeNull(); +}); + +test('device in sleep mode returns sleep image and correct refresh rate', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'sleep_mode_enabled' => true, + 'sleep_mode_from' => '19:00', + 'sleep_mode_to' => '23:00', + 'current_screen_image' => 'test-image', + ]); + + // Freeze time to 20:00 (within sleep window) + Carbon\Carbon::setTestNow(Carbon\Carbon::parse('2000-01-01 20:00:00')); + + $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(); + + // The filename should be a UUID-based PNG file since we're generating from template + expect($response['filename'])->toMatch('/^[a-f0-9-]+\.png$/'); + expect($response['refresh_rate'])->toBeGreaterThan(0); + + Carbon\Carbon::setTestNow(); // Clear test time +}); + +test('device not in sleep mode returns normal image', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'sleep_mode_enabled' => true, + 'sleep_mode_from' => '19:00', + 'sleep_mode_to' => '23:00', + 'current_screen_image' => 'test-image', + ]); + + // Freeze time to 18:00 (outside sleep window) + Carbon\Carbon::setTestNow(Carbon\Carbon::parse('2000-01-01 18:00:00')); + + $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() + ->assertJson([ + 'filename' => 'test-image.bmp', + ]); + + Carbon\Carbon::setTestNow(); // Clear test time +}); + +test('device returns sleep.png and correct refresh time when paused', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'pause_until' => now()->addMinutes(60), + ]); + + $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(); + $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['refresh_rate'])->toBeLessThanOrEqual(3600); // ~60 min +}); + +test('screens endpoint accepts nullable file_name', function (): void { + Queue::fake(); + + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + ]); + + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + ])->post('/api/screens', [ + 'image' => [ + 'content' => '
Test content
', + ], + ]); + + $response->assertOk(); + + Queue::assertPushed(GenerateScreenJob::class); +}); + +test('screens endpoint returns 404 for invalid device credentials', function (): void { + $response = $this->withHeaders([ + 'id' => 'invalid-mac', + 'access-token' => 'invalid-key', + ])->post('/api/screens', [ + 'image' => [ + 'content' => '
Test content
', + 'file_name' => 'test.blade.php', + ], + ]); + + $response->assertNotFound() + ->assertJson([ + 'message' => 'MAC Address not registered or invalid access token', + ]); +}); + +test('setup endpoint assigns device model when model-id header is provided', function (): void { + $user = User::factory()->create(['assign_new_devices' => true]); + $deviceModel = DeviceModel::factory()->create([ + 'name' => 'test-model', + 'label' => 'Test Model', + ]); + + $response = $this->withHeaders([ + 'id' => '00:11:22:33:44:55', + 'model-id' => 'test-model', + ])->get('/api/setup'); + + $response->assertOk() + ->assertJson([ + 'status' => 200, + 'message' => 'Welcome to TRMNL BYOS', + ]); + + $device = Device::where('mac_address', '00:11:22:33:44:55')->first(); + expect($device)->not->toBeNull() + ->and($device->device_model_id)->toBe($deviceModel->id); +}); + +test('setup endpoint handles non-existent device model gracefully', function (): void { + $user = User::factory()->create(['assign_new_devices' => true]); + + $response = $this->withHeaders([ + 'id' => '00:11:22:33:44:55', + 'model-id' => 'non-existent-model', + ])->get('/api/setup'); + + $response->assertOk() + ->assertJson([ + 'status' => 200, + 'message' => 'Welcome to TRMNL BYOS', + ]); + + $device = Device::where('mac_address', '00:11:22:33:44:55')->first(); + 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 new file mode 100644 index 0000000..a7db928 --- /dev/null +++ b/tests/Feature/Api/DeviceImageFormatTest.php @@ -0,0 +1,194 @@ +makeDirectory('/images/generated'); +}); + +test('device with firmware version 1.5.1 gets bmp format', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'current_screen_image' => 'test-image', + 'last_firmware_version' => '1.5.1', + ]); + + // Create both bmp and png files + Storage::disk('public')->put('images/generated/test-image.bmp', 'fake bmp content'); + Storage::disk('public')->put('images/generated/test-image.png', 'fake png content'); + + // Test /api/display endpoint + $displayResponse = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.5.1', + ])->get('/api/display'); + + $displayResponse->assertOk() + ->assertJson([ + 'filename' => 'test-image.bmp', + ]); + + // Test /api/current_screen endpoint + $currentScreenResponse = $this->withHeaders([ + 'access-token' => $device->api_key, + ])->get('/api/current_screen'); + + $currentScreenResponse->assertOk() + ->assertJson([ + 'filename' => 'test-image.bmp', + ]); +}); + +test('device with firmware version 1.5.2 gets png format', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'current_screen_image' => 'test-image', + 'last_firmware_version' => '1.5.2', + ]); + + // Create both bmp and png files + Storage::disk('public')->put('images/generated/test-image.png', 'fake bmp content'); + + // Test /api/display endpoint + $displayResponse = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.5.2', + ])->get('/api/display'); + + $displayResponse->assertOk() + ->assertJson([ + 'filename' => 'test-image.png', + ]); + + // Test /api/current_screen endpoint + $currentScreenResponse = $this->withHeaders([ + 'access-token' => $device->api_key, + ])->get('/api/current_screen'); + + $currentScreenResponse->assertOk() + ->assertJson([ + 'filename' => 'test-image.png', + ]); +}); + +test('device falls back to bmp when png does not exist', function (): void { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'current_screen_image' => 'test-image', + 'last_firmware_version' => '1.5.2', + ]); + + // Create only bmp file + Storage::disk('public')->put('images/generated/test-image.bmp', 'fake bmp content'); + + // Test /api/display endpoint + $displayResponse = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.5.2', + ])->get('/api/display'); + + $displayResponse->assertOk() + ->assertJson([ + 'filename' => 'test-image.bmp', + ]); + + // Test /api/current_screen endpoint + $currentScreenResponse = $this->withHeaders([ + 'access-token' => $device->api_key, + ])->get('/api/current_screen'); + + $currentScreenResponse->assertOk() + ->assertJson([ + 'filename' => 'test-image.bmp', + ]); +}); + +test('device without device_model_id and image_format bmp3_1bit_srgb returns bmp when plugin is rendered', function (): void { + // Create a user with auto-assign enabled + $user = User::factory()->create([ + 'assign_new_devices' => true, + ]); + + // Create a device without device_model_id and with bmp3_1bit_srgb format + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'device_model_id' => null, // Explicitly set to null + 'image_format' => ImageFormat::BMP3_1BIT_SRGB->value, + 'last_firmware_version' => '1.5.2', + ]); + + // Create a plugin + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'render_markup' => '
Test Content
', + 'data_strategy' => 'static', + 'markup_language' => 'blade', + 'current_image' => 'test-generated-image', // Set current image directly + ]); + + // Create a playlist for the device + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'is_active' => true, + 'refresh_time' => 900, + ]); + + // Create a playlist item with the plugin + $playlistItem = PlaylistItem::factory()->create([ + 'playlist_id' => $playlist->id, + 'plugin_id' => $plugin->id, + 'is_active' => true, + 'order' => 1, + ]); + + // Mock the image generation to create both bmp and png files + $imageUuid = 'test-generated-image'; + Storage::disk('public')->put('images/generated/'.$imageUuid.'.bmp', 'fake bmp content'); + Storage::disk('public')->put('images/generated/'.$imageUuid.'.png', 'fake png content'); + + // Set the device's current screen image to the plugin's image + $device->update(['current_screen_image' => $imageUuid]); + + // Test /api/display endpoint + $displayResponse = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.5.2', + ])->get('/api/current_screen'); + + $displayResponse->assertOk(); + $displayResponse->assertJson([ + 'filename' => $imageUuid.'.bmp', + ]); + + // Verify that the device's image_format is correctly set + $device->refresh(); + expect($device->image_format)->toBe(ImageFormat::BMP3_1BIT_SRGB->value) + ->and($device->device_model_id)->toBeNull(); +}); diff --git a/tests/Feature/Api/DeviceModelsEndpointTest.php b/tests/Feature/Api/DeviceModelsEndpointTest.php new file mode 100644 index 0000000..b37ec4f --- /dev/null +++ b/tests/Feature/Api/DeviceModelsEndpointTest.php @@ -0,0 +1,35 @@ +create(); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/device-models'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'name', + 'label', + 'description', + 'width', + 'height', + 'bit_depth', + ], + ], + ]); +}); + +it('blocks unauthenticated users from accessing device models', function (): void { + $response = $this->getJson('/api/device-models'); + + $response->assertUnauthorized(); +}); diff --git a/tests/Feature/Api/ImageWebhookTest.php b/tests/Feature/Api/ImageWebhookTest.php new file mode 100644 index 0000000..121f90a --- /dev/null +++ b/tests/Feature/Api/ImageWebhookTest.php @@ -0,0 +1,196 @@ +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 new file mode 100644 index 0000000..f0ad3d0 --- /dev/null +++ b/tests/Feature/Api/PluginSettingsArchiveTest.php @@ -0,0 +1,73 @@ +create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'uuid' => (string) Str::uuid(), + ]); + + // Authenticate via Sanctum (endpoint requires auth:sanctum) + Sanctum::actingAs($user); + + // Build a temporary ZIP with required structure: src/settings.yml and src/full.liquid + $tempDir = sys_get_temp_dir().'/trmnl_zip_'.uniqid(); + $srcDir = $tempDir.'/src'; + if (! is_dir($srcDir)) { + mkdir($srcDir, 0777, true); + } + + $settingsYaml = <<<'YAML' +name: Sample Imported +strategy: static +refresh_interval: 10 +custom_fields: + - keyname: title + default: "Hello" +static_data: '{"message":"world"}' +YAML; + + $fullLiquid = <<<'LIQUID' +

{{ config.title }}

+
{{ data.message }}
+LIQUID; + + file_put_contents($srcDir.'/settings.yml', $settingsYaml); + file_put_contents($srcDir.'/full.liquid', $fullLiquid); + + $zipPath = sys_get_temp_dir().'/plugin_'.uniqid().'.zip'; + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE); + $zip->addFile($srcDir.'/settings.yml', 'src/settings.yml'); + $zip->addFile($srcDir.'/full.liquid', 'src/full.liquid'); + $zip->close(); + + // Prepare UploadedFile + $uploaded = new UploadedFile($zipPath, 'plugin.zip', 'application/zip', null, true); + + // Make request (multipart form-data) + $response = $this->post('/api/plugin_settings/'.$plugin->uuid.'/archive', [ + 'file' => $uploaded, + ], ['Accept' => 'application/json']); + + $response->assertSuccessful(); + + $imported = Plugin::query() + ->where('user_id', $user->id) + ->where('name', 'Sample Imported') + ->first(); + + expect($imported)->not->toBeNull(); + expect($imported->markup_language)->toBe('liquid'); + expect($imported->render_markup)->toContain('

{{ config.title }}

'); + // Configuration should have default for title (set on create) + expect($imported->configuration['title'] ?? null)->toBe('Hello'); +}); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 61d04f1..07c1683 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -3,15 +3,15 @@ use App\Models\User; use Livewire\Volt\Volt as LivewireVolt; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('login screen can be rendered', function () { +test('login screen can be rendered', function (): void { $response = $this->get('/login'); $response->assertStatus(200); }); -test('users can authenticate using the login screen', function () { +test('users can authenticate using the login screen', function (): void { $user = User::factory()->create(); $response = LivewireVolt::test('auth.login') @@ -26,7 +26,7 @@ test('users can authenticate using the login screen', function () { $this->assertAuthenticated(); }); -test('users can not authenticate with invalid password', function () { +test('users can not authenticate with invalid password', function (): void { $user = User::factory()->create(); $this->post('/login', [ @@ -37,7 +37,7 @@ test('users can not authenticate with invalid password', function () { $this->assertGuest(); }); -test('users can logout', function () { +test('users can logout', function (): void { $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 37a205f..5cc2db8 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -5,9 +5,9 @@ use Illuminate\Auth\Events\Verified; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\URL; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('email verification screen can be rendered', function () { +test('email verification screen can be rendered', function (): void { $user = User::factory()->unverified()->create(); $response = $this->actingAs($user)->get('/verify-email'); @@ -15,7 +15,7 @@ test('email verification screen can be rendered', function () { $response->assertStatus(200); }); -test('email can be verified', function () { +test('email can be verified', function (): void { $user = User::factory()->unverified()->create(); Event::fake(); @@ -23,7 +23,7 @@ test('email can be verified', function () { $verificationUrl = URL::temporarySignedRoute( 'verification.verify', now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)] + ['id' => $user->id, 'hash' => sha1((string) $user->email)] ); $response = $this->actingAs($user)->get($verificationUrl); @@ -34,7 +34,7 @@ test('email can be verified', function () { $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); }); -test('email is not verified with invalid hash', function () { +test('email is not verified with invalid hash', function (): void { $user = User::factory()->unverified()->create(); $verificationUrl = URL::temporarySignedRoute( diff --git a/tests/Feature/Auth/OidcAuthenticationTest.php b/tests/Feature/Auth/OidcAuthenticationTest.php new file mode 100644 index 0000000..4a832b9 --- /dev/null +++ b/tests/Feature/Auth/OidcAuthenticationTest.php @@ -0,0 +1,149 @@ +shouldReceive('redirect')->andReturn(redirect('/fake-oidc-redirect')); + + // Default Socialite user returned by callback + $socialiteUser = mockSocialiteUser(); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver') + ->with('oidc') + ->andReturn($provider); +}); + +afterEach(function (): void { + Mockery::close(); +}); + +it('oidc redirect works when enabled', function (): void { + $response = $this->get(route('auth.oidc.redirect')); + + // Since we're using a mock OIDC provider, this will likely fail + // but we can check that the route exists and is accessible + expect($response->getStatusCode())->not->toBe(404); +}); + +it('oidc redirect fails when disabled', function (): void { + Config::set('services.oidc.enabled', false); + + $response = $this->get(route('auth.oidc.redirect')); + + $response->assertRedirect(route('login')); + $response->assertSessionHasErrors(['oidc' => 'OIDC authentication is not enabled.']); +}); + +it('oidc callback creates new user (placeholder)', function (): void { + mockSocialiteUser(); + + $this->get(route('auth.oidc.callback')); + + // We expect to be redirected to dashboard after successful authentication + // In a real test, this would be mocked properly + expect(true)->toBeTrue(); // Placeholder assertion +}); + +it('oidc callback updates existing user by oidc_sub (placeholder)', function (): void { + // Create a user with OIDC sub + User::factory()->create([ + 'oidc_sub' => 'test-sub-123', + 'name' => 'Old Name', + 'email' => 'old@example.com', + ]); + + mockSocialiteUser([ + 'id' => 'test-sub-123', + 'name' => 'Updated Name', + 'email' => 'updated@example.com', + ]); + + // This would need proper mocking of Socialite in a real test + expect(true)->toBeTrue(); // Placeholder assertion +}); + +it('oidc callback links existing user by email (placeholder)', function (): void { + // Create a user without OIDC sub but with matching email + User::factory()->create([ + 'oidc_sub' => null, + 'email' => 'test@example.com', + ]); + + mockSocialiteUser([ + 'id' => 'test-sub-456', + 'email' => 'test@example.com', + ]); + + // This would need proper mocking of Socialite in a real test + expect(true)->toBeTrue(); // Placeholder assertion +}); + +it('oidc callback fails when disabled', function (): void { + Config::set('services.oidc.enabled', false); + + $response = $this->get(route('auth.oidc.callback')); + + $response->assertRedirect(route('login')); + $response->assertSessionHasErrors(['oidc' => 'OIDC authentication is not enabled.']); +}); + +it('login view shows oidc button when enabled', function (): void { + $response = $this->get(route('login')); + + $response->assertStatus(200); + $response->assertSee('Continue with OIDC'); + $response->assertSee('Or'); +}); + +it('login view hides oidc button when disabled', function (): void { + Config::set('services.oidc.enabled', false); + + $response = $this->get(route('login')); + + $response->assertStatus(200); + $response->assertDontSee('Continue with OIDC'); +}); + +it('user model has oidc_sub fillable', function (): void { + $user = new User(); + + expect($user->getFillable())->toContain('oidc_sub'); +}); + +/** + * Mock a Socialite user for testing. + * + * @param array $userData + */ +function mockSocialiteUser(array $userData = []): SocialiteUser +{ + $defaultData = [ + 'id' => 'test-sub-123', + 'name' => 'Test User', + 'email' => 'test@example.com', + 'avatar' => null, + ]; + + $userData = array_merge($defaultData, $userData); + + /** @var SocialiteUser $socialiteUser */ + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn($userData['id']); + $socialiteUser->shouldReceive('getName')->andReturn($userData['name']); + $socialiteUser->shouldReceive('getEmail')->andReturn($userData['email']); + $socialiteUser->shouldReceive('getAvatar')->andReturn($userData['avatar']); + + return $socialiteUser; +} diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index 3f9b423..265963a 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -3,9 +3,9 @@ use App\Models\User; use Livewire\Volt\Volt; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('confirm password screen can be rendered', function () { +test('confirm password screen can be rendered', function (): void { $user = User::factory()->create(); $response = $this->actingAs($user)->get('/confirm-password'); @@ -13,7 +13,7 @@ test('confirm password screen can be rendered', function () { $response->assertStatus(200); }); -test('password can be confirmed', function () { +test('password can be confirmed', function (): void { $user = User::factory()->create(); $this->actingAs($user); @@ -27,7 +27,7 @@ test('password can be confirmed', function () { ->assertRedirect(route('dashboard', absolute: false)); }); -test('password is not confirmed with invalid password', function () { +test('password is not confirmed with invalid password', function (): void { $user = User::factory()->create(); $this->actingAs($user); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index b678d73..2f38263 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -5,15 +5,15 @@ use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Support\Facades\Notification; use Livewire\Volt\Volt; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('reset password link screen can be rendered', function () { +test('reset password link screen can be rendered', function (): void { $response = $this->get('/forgot-password'); $response->assertStatus(200); }); -test('reset password link can be requested', function () { +test('reset password link can be requested', function (): void { Notification::fake(); $user = User::factory()->create(); @@ -25,7 +25,7 @@ test('reset password link can be requested', function () { Notification::assertSentTo($user, ResetPassword::class); }); -test('reset password screen can be rendered', function () { +test('reset password screen can be rendered', function (): void { Notification::fake(); $user = User::factory()->create(); @@ -34,7 +34,7 @@ test('reset password screen can be rendered', function () { ->set('email', $user->email) ->call('sendPasswordResetLink'); - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + Notification::assertSentTo($user, ResetPassword::class, function ($notification): true { $response = $this->get('/reset-password/'.$notification->token); $response->assertStatus(200); @@ -43,7 +43,7 @@ test('reset password screen can be rendered', function () { }); }); -test('password can be reset with valid token', function () { +test('password can be reset with valid token', function (): void { Notification::fake(); $user = User::factory()->create(); @@ -52,7 +52,7 @@ test('password can be reset with valid token', function () { ->set('email', $user->email) ->call('sendPasswordResetLink'); - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user): true { $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 1ef6256..45bc39b 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -2,15 +2,15 @@ use Livewire\Volt\Volt; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('registration screen can be rendered', function () { +test('registration screen can be rendered', function (): void { $response = $this->get('/register'); $response->assertStatus(200); }); -test('new users can register', function () { +test('new users can register', function (): void { $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 new file mode 100644 index 0000000..74241e0 --- /dev/null +++ b/tests/Feature/Console/ExampleRecipesSeederCommandTest.php @@ -0,0 +1,40 @@ +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 b34357d..e8d12f0 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 () { +test('it dispatches fetch proxy cloud responses job', function (): void { // Prevent the job from actually running Bus::fake(); diff --git a/tests/Feature/Console/FirmwareCheckCommandTest.php b/tests/Feature/Console/FirmwareCheckCommandTest.php new file mode 100644 index 0000000..459a035 --- /dev/null +++ b/tests/Feature/Console/FirmwareCheckCommandTest.php @@ -0,0 +1,72 @@ +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 new file mode 100644 index 0000000..3e8c916 --- /dev/null +++ b/tests/Feature/Console/FirmwareUpdateCommandTest.php @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000..e2d35eb --- /dev/null +++ b/tests/Feature/Console/MashupCreateCommandTest.php @@ -0,0 +1,154 @@ +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 new file mode 100644 index 0000000..56ccea8 --- /dev/null +++ b/tests/Feature/Console/OidcTestCommandTest.php @@ -0,0 +1,197 @@ +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 54621d6..1f18107 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 () { +test('it generates screen with default parameters', function (): void { Bus::fake(); $this->artisan('trmnl:screen:generate') diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index e11099a..110adc8 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -2,14 +2,14 @@ use App\Models\User; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('guests are redirected to the login page', function () { +test('guests are redirected to the login page', function (): void { $response = $this->get('/dashboard'); $response->assertRedirect('/login'); }); -test('authenticated users can visit the dashboard', function () { +test('authenticated users can visit the dashboard', function (): void { $user = User::factory()->create(); $this->actingAs($user); diff --git a/tests/Feature/DeviceModelsTest.php b/tests/Feature/DeviceModelsTest.php new file mode 100644 index 0000000..14a374d --- /dev/null +++ b/tests/Feature/DeviceModelsTest.php @@ -0,0 +1,89 @@ +create(); + $deviceModels = DeviceModel::factory()->count(3)->create(); + + $response = $this->actingAs($user)->get('/device-models'); + + $response->assertSuccessful(); + $response->assertSee('Device Models'); + $response->assertSee('Add Device Model'); + + foreach ($deviceModels as $deviceModel) { + $response->assertSee($deviceModel->label); + $response->assertSee((string) $deviceModel->width); + $response->assertSee((string) $deviceModel->height); + $response->assertSee((string) $deviceModel->bit_depth); + } +}); + +it('allows creating a device model', function (): void { + $user = User::factory()->create(); + + $deviceModelData = [ + 'name' => 'test-model', + 'label' => 'Test Model', + 'description' => 'A test device model', + 'width' => 800, + 'height' => 600, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + ]; + + $deviceModel = DeviceModel::create($deviceModelData); + + $this->assertDatabaseHas('device_models', $deviceModelData); + expect($deviceModel->name)->toBe($deviceModelData['name']); +}); + +it('allows updating a device model', function (): void { + $user = User::factory()->create(); + $deviceModel = DeviceModel::factory()->create(); + + $updatedData = [ + 'name' => 'updated-model', + 'label' => 'Updated Model', + 'description' => 'An updated device model', + 'width' => 1024, + 'height' => 768, + 'colors' => 65536, + 'bit_depth' => 16, + 'scale_factor' => 1.5, + 'rotation' => 90, + 'mime_type' => 'image/jpeg', + 'offset_x' => 10, + 'offset_y' => 20, + ]; + + $deviceModel->update($updatedData); + + $this->assertDatabaseHas('device_models', $updatedData); + expect($deviceModel->fresh()->name)->toBe($updatedData['name']); +}); + +it('allows deleting a device model', function (): void { + $user = User::factory()->create(); + $deviceModel = DeviceModel::factory()->create(); + + $deviceModelId = $deviceModel->id; + $deviceModel->delete(); + + $this->assertDatabaseMissing('device_models', ['id' => $deviceModelId]); +}); + +it('redirects unauthenticated users from the device models page', function (): void { + $response = $this->get('/device-models'); + + $response->assertRedirect('/login'); +}); diff --git a/tests/Feature/Devices/DeviceConfigureTest.php b/tests/Feature/Devices/DeviceConfigureTest.php new file mode 100644 index 0000000..dff0954 --- /dev/null +++ b/tests/Feature/Devices/DeviceConfigureTest.php @@ -0,0 +1,25 @@ +create(); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'last_refreshed_at' => now()->subMinutes(5), + ]); + + $response = actingAs($user) + ->get(route('devices.configure', $device)); + + $response->assertOk() + ->assertSee('5 minutes ago'); +}); diff --git a/tests/Feature/Devices/DeviceRotationTest.php b/tests/Feature/Devices/DeviceRotationTest.php new file mode 100644 index 0000000..35367ba --- /dev/null +++ b/tests/Feature/Devices/DeviceRotationTest.php @@ -0,0 +1,87 @@ +create(); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'rotate' => 90, + 'current_screen_image' => 'test-image-uuid', + ]); + + // Mock the file existence check + Illuminate\Support\Facades\Storage::fake('public'); + Illuminate\Support\Facades\Storage::disk('public')->put('images/generated/test-image-uuid.png', 'fake-image-content'); + + $response = $this->actingAs($user) + ->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('-rotate-[90deg]'); + $response->assertSee('origin-center'); +}); + +test('device configure page shows device image with correct rotation', function (): void { + $user = User::factory()->create(); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'rotate' => 90, + 'current_screen_image' => 'test-image-uuid', + ]); + + // Mock the file existence check + Illuminate\Support\Facades\Storage::fake('public'); + Illuminate\Support\Facades\Storage::disk('public')->put('images/generated/test-image-uuid.png', 'fake-image-content'); + + $response = $this->actingAs($user) + ->get(route('devices.configure', $device)); + + $response->assertSuccessful(); + $response->assertSee('-rotate-[90deg]'); + $response->assertSee('origin-center'); +}); + +test('device with no rotation shows no transform style', function (): void { + $user = User::factory()->create(); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'rotate' => 0, + 'current_screen_image' => 'test-image-uuid', + ]); + + // Mock the file existence check + Illuminate\Support\Facades\Storage::fake('public'); + Illuminate\Support\Facades\Storage::disk('public')->put('images/generated/test-image-uuid.png', 'fake-image-content'); + + $response = $this->actingAs($user) + ->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('-rotate-[0deg]'); +}); + +test('device with null rotation defaults to 0', function (): void { + $user = User::factory()->create(); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'rotate' => null, + 'current_screen_image' => 'test-image-uuid', + ]); + + // Mock the file existence check + Illuminate\Support\Facades\Storage::fake('public'); + Illuminate\Support\Facades\Storage::disk('public')->put('images/generated/test-image-uuid.png', 'fake-image-content'); + + $response = $this->actingAs($user) + ->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('-rotate-[0deg]'); +}); diff --git a/tests/Feature/Devices/DeviceTest.php b/tests/Feature/Devices/DeviceTest.php index ad12879..3cff76b 100644 --- a/tests/Feature/Devices/DeviceTest.php +++ b/tests/Feature/Devices/DeviceTest.php @@ -1,10 +1,11 @@ create([ 'name' => 'Test Device', ]); @@ -13,7 +14,7 @@ test('device can be created with basic attributes', function () { ->and($device->name)->toBe('Test Device'); }); -test('battery percentage is calculated correctly', function () { +test('battery percentage is calculated correctly', function (): void { $cases = [ ['voltage' => 3.0, 'expected' => 0], // Min voltage ['voltage' => 4.2, 'expected' => 100], // Max voltage @@ -33,7 +34,7 @@ test('battery percentage is calculated correctly', function () { } }); -test('wifi strength is determined correctly', function () { +test('wifi strength is determined correctly', function (): void { $cases = [ ['rssi' => 0, 'expected' => 0], // No signal ['rssi' => -90, 'expected' => 1], // Weak signal @@ -46,12 +47,12 @@ test('wifi strength is determined correctly', function () { 'last_rssi_level' => $case['rssi'], ]); - expect($device->wifi_strengh)->toBe($case['expected']) + expect($device->wifi_strength)->toBe($case['expected']) ->and($device->last_rssi_level)->toBe($case['rssi']); } }); -test('proxy cloud attribute is properly cast to boolean', function () { +test('proxy cloud attribute is properly cast to boolean', function (): void { $device = Device::factory()->create([ 'proxy_cloud' => true, ]); @@ -62,7 +63,7 @@ test('proxy cloud attribute is properly cast to boolean', function () { expect($device->proxy_cloud)->toBeFalse(); }); -test('last log request is properly cast to json', function () { +test('last log request is properly cast to json', function (): void { $logData = ['status' => 'success', 'timestamp' => '2024-03-04 12:00:00']; $device = Device::factory()->create([ @@ -74,3 +75,20 @@ test('last log request is properly cast to json', function () { ->toHaveKey('status') ->toHaveKey('timestamp'); }); + +test('getSleepModeEndsInSeconds returns correct value for overnight sleep window', function (): void { + // Set the current time to 12:13 + Carbon::setTestNow(Carbon::create(2024, 1, 1, 12, 13, 0)); + + $device = Device::factory()->create([ + 'sleep_mode_enabled' => true, + 'sleep_mode_from' => Carbon::create(2024, 1, 1, 22, 0, 0), // 22:00 + 'sleep_mode_to' => Carbon::create(2024, 1, 1, 13, 0, 0), // 13:00 + ]); + + $seconds = $device->getSleepModeEndsInSeconds(); + // 47 minutes = 2820 seconds + expect($seconds)->toBe(2820); + + Carbon::setTestNow(); // Clear test time +}); diff --git a/tests/Feature/Devices/ManageTest.php b/tests/Feature/Devices/ManageTest.php index b54d6a8..fbfd2f2 100644 --- a/tests/Feature/Devices/ManageTest.php +++ b/tests/Feature/Devices/ManageTest.php @@ -4,9 +4,9 @@ use App\Models\Device; use App\Models\User; use Livewire\Volt\Volt; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('device management page can be rendered', function () { +test('device management page can be rendered', function (): void { $user = User::factory()->create(); $response = $this->actingAs($user) @@ -15,7 +15,7 @@ test('device management page can be rendered', function () { $response->assertOk(); }); -test('user can create a new device', function () { +test('user can create a new device', function (): void { $user = User::factory()->create(); $this->actingAs($user); @@ -48,7 +48,7 @@ test('user can create a new device', function () { expect($device->user_id)->toBe($user->id); }); -test('device creation requires required fields', function () { +test('device creation requires required fields', function (): void { $user = User::factory()->create(); $this->actingAs($user); @@ -67,7 +67,7 @@ test('device creation requires required fields', function () { ]); }); -test('user can toggle proxy cloud for their device', function () { +test('user can toggle proxy cloud for their device', function (): void { $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 () { expect($device->fresh()->proxy_cloud)->toBeFalse(); }); -test('user cannot toggle proxy cloud for other users devices', function () { +test('user cannot toggle proxy cloud for other users devices', function (): void { $user = User::factory()->create(); $this->actingAs($user); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8b5843f..34782b1 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 new file mode 100644 index 0000000..e09ff4c --- /dev/null +++ b/tests/Feature/FetchDeviceModelsCommandTest.php @@ -0,0 +1,20 @@ +artisan('device-models:fetch') + ->expectsOutput('Dispatching FetchDeviceModelsJob...') + ->expectsOutput('FetchDeviceModelsJob has been dispatched successfully.') + ->assertExitCode(0); + + Queue::assertPushed(FetchDeviceModelsJob::class); +}); diff --git a/tests/Feature/FetchProxyCloudResponsesTest.php b/tests/Feature/FetchProxyCloudResponsesTest.php index c9a3b63..561dc1c 100644 --- a/tests/Feature/FetchProxyCloudResponsesTest.php +++ b/tests/Feature/FetchProxyCloudResponsesTest.php @@ -5,14 +5,20 @@ use App\Models\Device; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -beforeEach(function () { +beforeEach(function (): void { Storage::fake('public'); Storage::disk('public')->makeDirectory('/images/generated'); + Http::preventStrayRequests(); + Http::fake([ + 'https://example.com/test-image.bmp*' => Http::response([], 200), + 'https://trmnl.app/api/log' => Http::response([], 200), + 'https://example.com/api/log' => Http::response([], 200), + ]); }); -test('it fetches and processes proxy cloud responses for devices', function () { +test('it fetches and processes proxy cloud responses for devices', function (): void { config(['services.trmnl.proxy_base_url' => 'https://example.com']); // Create a test device with proxy cloud enabled @@ -53,27 +59,28 @@ test('it fetches and processes proxy cloud responses for devices', function () { $job->handle(); // Assert HTTP requests were made with correct headers - 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); - }); + 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)); // Assert the device was updated $device->refresh(); expect($device->current_screen_image)->toBe('test-image') - ->and($device->proxy_cloud_response)->toBe('{"image_url":"https:\\/\\/example.com\\/test-image.bmp","filename":"test-image"}'); + ->and($device->proxy_cloud_response)->toBe([ + 'image_url' => 'https://example.com/test-image.bmp', + 'filename' => 'test-image', + ]); // Assert the image was saved Storage::disk('public')->assertExists('images/generated/test-image.bmp'); }); -test('it handles log requests when present', function () { +test('it handles log requests when present', function (): void { $device = Device::factory()->create([ 'proxy_cloud' => true, 'mac_address' => '00:11:22:33:44:55', @@ -94,18 +101,16 @@ test('it handles log requests when present', function () { $job->handle(); // Assert log request was sent - 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']); - }); + 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'])); // Assert log request was cleared $device->refresh(); expect($device->last_log_request)->toBeNull(); }); -test('it handles API errors gracefully', function () { +test('it handles API errors gracefully', function (): void { $device = Device::factory()->create([ 'proxy_cloud' => true, 'mac_address' => '00:11:22:33:44:55', @@ -121,7 +126,7 @@ test('it handles API errors gracefully', function () { expect(fn () => $job->handle())->not->toThrow(Exception::class); }); -test('it only processes proxy cloud enabled devices', function () { +test('it only processes proxy cloud enabled devices', function (): void { Http::fake(); $enabledDevice = Device::factory()->create(['proxy_cloud' => true]); $disabledDevice = Device::factory()->create(['proxy_cloud' => false]); @@ -130,11 +135,146 @@ test('it only processes proxy cloud enabled devices', function () { $job->handle(); // Assert request was only made for enabled device - Http::assertSent(function ($request) use ($enabledDevice) { - return $request->hasHeader('id', $enabledDevice->mac_address); - }); + Http::assertSent(fn ($request) => $request->hasHeader('id', $enabledDevice->mac_address)); - Http::assertNotSent(function ($request) use ($disabledDevice) { - return $request->hasHeader('id', $disabledDevice->mac_address); - }); + Http::assertNotSent(fn ($request) => $request->hasHeader('id', $disabledDevice->mac_address)); +}); + +test('it fetches and processes proxy cloud responses for devices with BMP images', function (): void { + config(['services.trmnl.proxy_base_url' => 'https://example.com']); + + // Create a test device with proxy cloud enabled + $device = Device::factory()->create([ + 'proxy_cloud' => true, + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'last_rssi_level' => -70, + 'last_battery_voltage' => 3.7, + 'default_refresh_interval' => 300, + 'last_firmware_version' => '1.0.0', + ]); + + // Mock the API response with BMP image + Http::fake([ + config('services.trmnl.proxy_base_url').'/api/display' => Http::response([ + 'image_url' => 'https://example.com/test-image.bmp?response-content-type=image/bmp', + 'filename' => 'test-image', + ]), + 'https://example.com/test-image.bmp?response-content-type=image/bmp' => Http::response('fake-image-content'), + ]); + + // Run the job + $job = new FetchProxyCloudResponses; + $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)); + + // Assert the device was updated + $device->refresh(); + + expect($device->current_screen_image)->toBe('test-image') + ->and($device->proxy_cloud_response)->toBe([ + 'image_url' => 'https://example.com/test-image.bmp?response-content-type=image/bmp', + 'filename' => 'test-image', + ]); + + // Assert the image was saved with BMP extension + expect(Storage::disk('public')->exists('images/generated/test-image.bmp'))->toBeTrue(); + 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 { + config(['services.trmnl.proxy_base_url' => 'https://example.com']); + + // Create a test device with proxy cloud enabled + $device = Device::factory()->create([ + 'proxy_cloud' => true, + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'last_rssi_level' => -70, + 'last_battery_voltage' => 3.7, + 'default_refresh_interval' => 300, + 'last_firmware_version' => '1.0.0', + ]); + + // Mock the API response with PNG image + Http::fake([ + config('services.trmnl.proxy_base_url').'/api/display' => Http::response([ + 'image_url' => 'https://example.com/test-image.png?response-content-type=image/png', + 'filename' => 'test-image', + ]), + 'https://example.com/test-image.png?response-content-type=image/png' => Http::response('fake-image-content'), + ]); + + // Run the job + $job = new FetchProxyCloudResponses; + $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)); + + // Assert the device was updated + $device->refresh(); + + expect($device->current_screen_image)->toBe('test-image') + ->and($device->proxy_cloud_response)->toBe([ + 'image_url' => 'https://example.com/test-image.png?response-content-type=image/png', + 'filename' => 'test-image', + ]); + + // Assert the image was saved with PNG extension + expect(Storage::disk('public')->exists('images/generated/test-image.png'))->toBeTrue(); + expect(Storage::disk('public')->exists('images/generated/test-image.bmp'))->toBeFalse(); +}); + +test('it handles missing content type in image URL gracefully', function (): void { + config(['services.trmnl.proxy_base_url' => 'https://example.com']); + + // Create a test device with proxy cloud enabled + $device = Device::factory()->create([ + 'proxy_cloud' => true, + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + ]); + + // Mock the API response with no content type in URL + Http::fake([ + config('services.trmnl.proxy_base_url').'/api/display' => Http::response([ + 'image_url' => 'https://example.com/test-image.bmp', + 'filename' => 'test-image', + ]), + 'https://example.com/test-image.bmp' => Http::response('fake-image-content'), + ]); + + // Run the job + $job = new FetchProxyCloudResponses; + $job->handle(); + + // Assert the device was updated + $device->refresh(); + + expect($device->current_screen_image)->toBe('test-image') + ->and($device->proxy_cloud_response)->toBe([ + 'image_url' => 'https://example.com/test-image.bmp', + 'filename' => 'test-image', + ]); + + // Assert the image was saved with default BMP extension + expect(Storage::disk('public')->exists('images/generated/test-image.bmp'))->toBeTrue(); + expect(Storage::disk('public')->exists('images/generated/test-image.png'))->toBeFalse(); }); diff --git a/tests/Feature/GenerateDefaultImagesTest.php b/tests/Feature/GenerateDefaultImagesTest.php new file mode 100644 index 0000000..5a7b69a --- /dev/null +++ b/tests/Feature/GenerateDefaultImagesTest.php @@ -0,0 +1,113 @@ +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 e1f24ab..115fb51 100644 --- a/tests/Feature/GenerateScreenJobTest.php +++ b/tests/Feature/GenerateScreenJobTest.php @@ -2,18 +2,20 @@ use App\Jobs\GenerateScreenJob; use App\Models\Device; +use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Illuminate\Support\Facades\Storage; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -beforeEach(function () { +beforeEach(function (): void { + TrmnlPipeline::fake(); Storage::fake('public'); Storage::disk('public')->makeDirectory('/images/generated'); }); -test('it generates screen images and updates device', function () { +test('it generates screen images and updates device', function (): void { $device = Device::factory()->create(); - $job = new GenerateScreenJob($device->id, view('trmnl')->render()); + $job = new GenerateScreenJob($device->id, null, view('trmnl')->render()); $job->handle(); // Assert the device was updated with a new image UUID @@ -23,10 +25,9 @@ test('it generates screen images and updates device', function () { // Assert both PNG and BMP files were created $uuid = $device->current_screen_image; Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); - Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp"); -})->skipOnGitHubActions(); +}); -test('it cleans up unused images', function () { +test('it cleans up unused images', function (): void { // Create some test devices with images $activeDevice = Device::factory()->create([ 'current_screen_image' => 'uuid-to-be-replaced', @@ -39,21 +40,21 @@ test('it cleans up unused images', function () { Storage::disk('public')->put('/images/generated/inactive-uuid.bmp', 'test'); // Run a job which will trigger cleanup - $job = new GenerateScreenJob($activeDevice->id, '
Test
'); + $job = new GenerateScreenJob($activeDevice->id, null, '
Test
'); $job->handle(); Storage::disk('public')->assertMissing('/images/generated/uuid-to-be-replaced.png'); 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'); -})->skipOnGitHubActions(); +}); -test('it preserves gitignore file during cleanup', function () { +test('it preserves gitignore file during cleanup', function (): void { Storage::disk('public')->put('/images/generated/.gitignore', '*'); $device = Device::factory()->create(); - $job = new GenerateScreenJob($device->id, '
Test
'); + $job = new GenerateScreenJob($device->id, null, '
Test
'); $job->handle(); Storage::disk('public')->assertExists('/images/generated/.gitignore'); -})->skipOnGitHubActions(); +}); diff --git a/tests/Feature/ImageGenerationServiceTest.php b/tests/Feature/ImageGenerationServiceTest.php new file mode 100644 index 0000000..07bb6a6 --- /dev/null +++ b/tests/Feature/ImageGenerationServiceTest.php @@ -0,0 +1,426 @@ +makeDirectory('/images/generated'); + TrmnlPipeline::fake(); +}); + +afterEach(function (): void { + TrmnlPipeline::restore(); +}); + +it('generates image for device without device model', function (): void { + // Create a device without a DeviceModel (legacy behavior) + $device = Device::factory()->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 0, + 'image_format' => ImageFormat::PNG_8BIT_GRAYSCALE->value, + ]); + + $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"); +}); + +it('generates image for device with device model', function (): void { + // Create a DeviceModel + $deviceModel = DeviceModel::factory()->create([ + 'width' => 1024, + 'height' => 768, + 'colors' => 256, + 'bit_depth' => 8, + '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"); +}); + +it('generates BMP with device model', function (): void { + // Create a DeviceModel for BMP format + $deviceModel = DeviceModel::factory()->create([ + 'width' => 800, + 'height' => 480, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/bmp', + '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 BMP file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp"); +}); + +it('applies scale factor from device model', function (): void { + // Create a DeviceModel with scale factor + $deviceModel = DeviceModel::factory()->create([ + 'width' => 800, + 'height' => 480, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 2.0, // Scale up by 2x + '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"); +}); + +it('applies rotation from device model', function (): void { + // Create a DeviceModel with rotation + $deviceModel = DeviceModel::factory()->create([ + 'width' => 800, + 'height' => 480, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1.0, + 'rotation' => 90, // Rotate 90 degrees + '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"); +}); + +it('applies offset from device model', function (): void { + // Create a DeviceModel with offset + $deviceModel = DeviceModel::factory()->create([ + 'width' => 800, + 'height' => 480, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 10, // Offset by 10 pixels + 'offset_y' => 20, // Offset by 20 pixels + ]); + + // 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"); +}); + +it('falls back to device settings when no device model', function (): void { + // Create a device with custom settings but no DeviceModel + $device = Device::factory()->create([ + 'width' => 1024, + 'height' => 768, + 'rotate' => 180, + 'image_format' => ImageFormat::PNG_8BIT_256C->value, + ]); + + $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"); +}); + +it('handles auto image format for legacy devices', function (): void { + // Create a device with AUTO format (legacy behavior) + $device = Device::factory()->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 0, + 'image_format' => ImageFormat::AUTO->value, + 'last_firmware_version' => '1.6.0', // Modern firmware + ]); + + $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 (modern firmware defaults to PNG) + Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); +}); + +it('cleanupFolder removes unused images', function (): void { + // Create active devices with images + Device::factory()->create(['current_screen_image' => 'active-uuid-1']); + Device::factory()->create(['current_screen_image' => 'active-uuid-2']); + + // Create some test files + Storage::disk('public')->put('/images/generated/active-uuid-1.png', 'test'); + Storage::disk('public')->put('/images/generated/active-uuid-2.png', 'test'); + Storage::disk('public')->put('/images/generated/inactive-uuid.png', 'test'); + Storage::disk('public')->put('/images/generated/another-inactive.png', 'test'); + + // Run cleanup + ImageGenerationService::cleanupFolder(); + + // Assert active files are preserved + Storage::disk('public')->assertExists('/images/generated/active-uuid-1.png'); + Storage::disk('public')->assertExists('/images/generated/active-uuid-2.png'); + + // Assert inactive files are removed + Storage::disk('public')->assertMissing('/images/generated/inactive-uuid.png'); + Storage::disk('public')->assertMissing('/images/generated/another-inactive.png'); +}); + +it('cleanupFolder preserves .gitignore', function (): void { + // Create gitignore file + Storage::disk('public')->put('/images/generated/.gitignore', '*'); + + // Create some test files + Storage::disk('public')->put('/images/generated/test.png', 'test'); + + // Run cleanup + ImageGenerationService::cleanupFolder(); + + // Assert gitignore is preserved + Storage::disk('public')->assertExists('/images/generated/.gitignore'); +}); + +it('resetIfNotCacheable resets when device models exist', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create a device with DeviceModel (should trigger cache reset) + Device::factory()->create([ + 'device_model_id' => DeviceModel::factory()->create()->id, + ]); + + // Run reset check + ImageGenerationService::resetIfNotCacheable($plugin); + + // Assert plugin image was reset + $plugin->refresh(); + expect($plugin->current_image)->toBeNull(); +}); + +it('resetIfNotCacheable resets when custom dimensions exist', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create a device with custom dimensions (should trigger cache reset) + Device::factory()->create([ + 'width' => 1024, // Different from default 800 + 'height' => 768, // Different from default 480 + ]); + + // Run reset check + ImageGenerationService::resetIfNotCacheable($plugin); + + // Assert plugin image was reset + $plugin->refresh(); + expect($plugin->current_image)->toBeNull(); +}); + +it('resetIfNotCacheable preserves image for standard devices', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create devices with standard dimensions (should not trigger cache reset) + Device::factory()->count(3)->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 0, + ]); + + // Run reset check + ImageGenerationService::resetIfNotCacheable($plugin); + + // 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(); +}); + +it('determines correct image format from device model', function (): void { + // Test BMP format detection + $bmpModel = DeviceModel::factory()->create([ + 'mime_type' => 'image/bmp', + 'bit_depth' => 1, + 'colors' => 2, + ]); + + $device = Device::factory()->create(['device_model_id' => $bmpModel->id]); + $markup = '
Test
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp"); + + // Test PNG 8-bit grayscale format detection + $pngGrayscaleModel = DeviceModel::factory()->create([ + 'mime_type' => 'image/png', + 'bit_depth' => 8, + 'colors' => 2, + ]); + + $device2 = Device::factory()->create(['device_model_id' => $pngGrayscaleModel->id]); + $uuid2 = ImageGenerationService::generateImage($markup, $device2->id); + + $device2->refresh(); + expect($device2->current_screen_image)->toBe($uuid2); + Storage::disk('public')->assertExists("/images/generated/{$uuid2}.png"); + + // Test PNG 8-bit 256 color format detection + $png256Model = DeviceModel::factory()->create([ + 'mime_type' => 'image/png', + 'bit_depth' => 8, + 'colors' => 256, + ]); + + $device3 = Device::factory()->create(['device_model_id' => $png256Model->id]); + $uuid3 = ImageGenerationService::generateImage($markup, $device3->id); + + $device3->refresh(); + expect($device3->current_screen_image)->toBe($uuid3); + Storage::disk('public')->assertExists("/images/generated/{$uuid3}.png"); +}); + +it('generates BMP for legacy device with bmp3_1bit_srgb format', function (): void { + // Create a device with BMP format but no DeviceModel (legacy behavior) + $device = Device::factory()->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 0, + 'image_format' => ImageFormat::BMP3_1BIT_SRGB->value, + 'device_model_id' => null, // Explicitly no DeviceModel + ]); + + $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 BMP file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp"); + + // Verify the BMP file has content and isn't blank + $imagePath = Storage::disk('public')->path("/images/generated/{$uuid}.bmp"); + $imageSize = filesize($imagePath); + expect($imageSize)->toBeGreaterThan(100); // Should be at least 100 bytes for a BMP + + // Verify it's a valid BMP file + $imageInfo = getimagesize($imagePath); + expect($imageInfo[0])->toBe(800); // Width + expect($imageInfo[1])->toBe(480); // Height + expect($imageInfo[2])->toBe(IMAGETYPE_BMP); // BMP type +}); diff --git a/tests/Feature/ImageGenerationWithoutFakeTest.php b/tests/Feature/ImageGenerationWithoutFakeTest.php new file mode 100644 index 0000000..ff70174 --- /dev/null +++ b/tests/Feature/ImageGenerationWithoutFakeTest.php @@ -0,0 +1,55 @@ +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 new file mode 100644 index 0000000..ae2833b --- /dev/null +++ b/tests/Feature/Jobs/CleanupDeviceLogsJobTest.php @@ -0,0 +1,44 @@ +create(); + $device2 = Device::factory()->create(); + + // Create 60 logs for each device with different timestamps + for ($i = 0; $i < 60; ++$i) { + DeviceLog::factory()->create([ + 'device_id' => $device1->id, + 'device_timestamp' => now()->subMinutes($i), + ]); + + DeviceLog::factory()->create([ + 'device_id' => $device2->id, + 'device_timestamp' => now()->subMinutes($i), + ]); + } + + // Run the cleanup job + CleanupDeviceLogsJob::dispatchSync(); + + // Assert each device has exactly 50 logs + expect($device1->logs()->count())->toBe(50) + ->and($device2->logs()->count())->toBe(50); + + // Assert the remaining logs are the most recent ones + $device1Logs = $device1->logs()->orderByDesc('device_timestamp')->get(); + $device2Logs = $device2->logs()->orderByDesc('device_timestamp')->get(); + + // Check that the timestamps are in descending order + for ($i = 0; $i < 49; ++$i) { + expect($device1Logs[$i]->device_timestamp->gt($device1Logs[$i + 1]->device_timestamp))->toBeTrue() + ->and($device2Logs[$i]->device_timestamp->gt($device2Logs[$i + 1]->device_timestamp))->toBeTrue(); + } +}); diff --git a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php new file mode 100644 index 0000000..f0be135 --- /dev/null +++ b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php @@ -0,0 +1,409 @@ + 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 new file mode 100644 index 0000000..f9109bb --- /dev/null +++ b/tests/Feature/Jobs/FirmwareDownloadJobTest.php @@ -0,0 +1,160 @@ +makeDirectory('/firmwares'); +}); + +test('it creates firmwares directory if it does not exist', 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), + ]); + + (new FirmwareDownloadJob($firmware))->handle(); + + expect(Storage::disk('public')->exists('firmwares'))->toBeTrue(); +}); + +test('it downloads firmware and updates storage location', 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', + '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 new file mode 100644 index 0000000..74c3cf7 --- /dev/null +++ b/tests/Feature/Jobs/FirmwarePollJobTest.php @@ -0,0 +1,116 @@ + Http::response([ + 'version' => '1.0.0', + 'url' => 'https://example.com/firmware.bin', + ], 200), + ]); + + (new FirmwarePollJob)->handle(); + + expect(Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue() + ->and(Firmware::where('version_tag', '1.0.0')->first()) + ->url->toBe('https://example.com/firmware.bin') + ->latest->toBeTrue(); +}); + +test('it updates existing firmware record when polling', function (): void { + $existingFirmware = Firmware::factory()->create([ + 'version_tag' => '1.0.0', + 'url' => 'https://old-url.com/firmware.bin', + 'latest' => true, + ]); + + Http::fake([ + 'https://usetrmnl.com/api/firmware/latest' => Http::response([ + 'version' => '1.0.0', + 'url' => 'https://new-url.com/firmware.bin', + ], 200), + ]); + + (new FirmwarePollJob)->handle(); + + expect($existingFirmware->fresh()) + ->url->toBe('https://new-url.com/firmware.bin') + ->latest->toBeTrue(); +}); + +test('it marks previous firmware as not latest when new version is found', function (): void { + $oldFirmware = Firmware::factory()->create([ + 'version_tag' => '1.0.0', + 'latest' => true, + ]); + + Http::fake([ + 'https://usetrmnl.com/api/firmware/latest' => Http::response([ + 'version' => '1.1.0', + 'url' => 'https://example.com/firmware.bin', + ], 200), + ]); + + (new FirmwarePollJob)->handle(); + + expect($oldFirmware->fresh()->latest)->toBeFalse() + ->and(Firmware::where('version_tag', '1.1.0')->first()->latest)->toBeTrue(); +}); + +test('it handles connection exception gracefully', function (): void { + Http::fake([ + 'https://usetrmnl.com/api/firmware/latest' => function (): void { + throw new ConnectionException('Connection failed'); + }, + ]); + + (new FirmwarePollJob)->handle(); + + // Verify no firmware records were created + expect(Firmware::count())->toBe(0); +}); + +test('it handles invalid response gracefully', function (): void { + Http::fake([ + 'https://usetrmnl.com/api/firmware/latest' => Http::response(null, 200), + ]); + + (new FirmwarePollJob)->handle(); + + // Verify no firmware records were created + expect(Firmware::count())->toBe(0); +}); + +test('it handles missing version in response gracefully', function (): void { + Http::fake([ + 'https://usetrmnl.com/api/firmware/latest' => Http::response([ + 'url' => 'https://example.com/firmware.bin', + ], 200), + ]); + + (new FirmwarePollJob)->handle(); + + // Verify no firmware records were created + expect(Firmware::count())->toBe(0); +}); + +test('it handles missing url in response gracefully', function (): void { + Http::fake([ + 'https://usetrmnl.com/api/firmware/latest' => Http::response([ + 'version' => '1.0.0', + ], 200), + ]); + + (new FirmwarePollJob)->handle(); + + // Verify no firmware records were created + expect(Firmware::count())->toBe(0); +}); diff --git a/tests/Feature/Jobs/NotifyDeviceBatteryLowJobTest.php b/tests/Feature/Jobs/NotifyDeviceBatteryLowJobTest.php new file mode 100644 index 0000000..6d69924 --- /dev/null +++ b/tests/Feature/Jobs/NotifyDeviceBatteryLowJobTest.php @@ -0,0 +1,140 @@ + 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 new file mode 100644 index 0000000..5d8b057 --- /dev/null +++ b/tests/Feature/Livewire/Actions/DeviceAutoJoinTest.php @@ -0,0 +1,115 @@ +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 new file mode 100644 index 0000000..1b2efba --- /dev/null +++ b/tests/Feature/Livewire/Catalog/IndexTest.php @@ -0,0 +1,199 @@ + 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 new file mode 100644 index 0000000..4372991 --- /dev/null +++ b/tests/Feature/Livewire/Plugins/ConfigModalTest.php @@ -0,0 +1,124 @@ +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 new file mode 100644 index 0000000..a04815f --- /dev/null +++ b/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php @@ -0,0 +1,112 @@ +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 new file mode 100644 index 0000000..18d0032 --- /dev/null +++ b/tests/Feature/PlaylistSchedulingTest.php @@ -0,0 +1,177 @@ +create(); + $device = Device::factory()->create(['user_id' => $user->id]); + + // Create two playlists with overlapping time ranges spanning midnight + $playlist1 = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'Day until Deep Night Playlist', + 'is_active' => true, + 'active_from' => '09:01', + 'active_until' => '03:58', + 'weekdays' => null, // Active every day + ]); + + $playlist2 = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'Early Morning Playlist', + 'is_active' => true, + 'active_from' => '04:00', + 'active_until' => '09:00', + 'weekdays' => null, // Active every day + ]); + + // Create plugins and playlist items + $plugin1 = Plugin::factory()->create(['name' => 'Day & Deep Night Plugin']); + $plugin2 = Plugin::factory()->create(['name' => 'Morning Plugin']); + + PlaylistItem::factory()->create([ + 'playlist_id' => $playlist1->id, + 'plugin_id' => $plugin1->id, + 'order' => 1, + 'is_active' => true, + ]); + + PlaylistItem::factory()->create([ + 'playlist_id' => $playlist2->id, + 'plugin_id' => $plugin2->id, + 'order' => 1, + 'is_active' => true, + ]); + + // Test at 10:00 AM - should get playlist2 (Early Morning Playlist) + Carbon::setTestNow(Carbon::create(2024, 1, 1, 4, 0, 0)); + + $nextItem = $device->getNextPlaylistItem(); + expect($nextItem)->not->toBeNull(); + expect($nextItem->plugin->name)->toBe('Morning Plugin'); + expect($nextItem->playlist->name)->toBe('Early Morning Playlist'); + + // Test at 2:00 AM - should get playlist1 (Day until Deep Night Playlist) + Carbon::setTestNow(Carbon::create(2024, 1, 1, 10, 0, 0)); + + $nextItem = $device->getNextPlaylistItem(); + expect($nextItem)->not->toBeNull(); + expect($nextItem->plugin->name)->toBe('Day & Deep Night Plugin'); + expect($nextItem->playlist->name)->toBe('Day until Deep Night Playlist'); + + // Test at 5:00 AM - should get playlist2 (Early Morning Playlist) + Carbon::setTestNow(Carbon::create(2024, 1, 1, 8, 0, 0)); + + $nextItem = $device->getNextPlaylistItem(); + expect($nextItem)->not->toBeNull(); + expect($nextItem->plugin->name)->toBe('Morning Plugin'); + expect($nextItem->playlist->name)->toBe('Early Morning Playlist'); + + // Test at 11:00 PM - should get playlist1 (Day until Deep Night Playlist) + Carbon::setTestNow(Carbon::create(2024, 1, 1, 23, 0, 0)); + + $nextItem = $device->getNextPlaylistItem(); + expect($nextItem)->not->toBeNull(); + expect($nextItem->plugin->name)->toBe('Day & Deep Night Plugin'); + expect($nextItem->playlist->name)->toBe('Day until Deep Night Playlist'); +}); + +test('playlist isActiveNow handles midnight spanning correctly', function (): void { + $playlist = Playlist::factory()->create([ + 'is_active' => true, + 'active_from' => '09:01', + 'active_until' => '03:58', + 'weekdays' => null, + ]); + + // Test at 2:00 AM - should be active + Carbon::setTestNow(Carbon::create(2024, 1, 1, 2, 0, 0)); + expect($playlist->isActiveNow())->toBeTrue(); + + // Test at 10:00 AM - should be active + Carbon::setTestNow(Carbon::create(2024, 1, 1, 10, 0, 0)); + expect($playlist->isActiveNow())->toBeTrue(); + + // Test at 5:00 AM - should NOT be active (gap between playlists) + Carbon::setTestNow(Carbon::create(2024, 1, 1, 5, 0, 0)); + expect($playlist->isActiveNow())->toBeFalse(); + + // Test at 8:00 AM - should NOT be active (gap between playlists) + Carbon::setTestNow(Carbon::create(2024, 1, 1, 8, 0, 0)); + expect($playlist->isActiveNow())->toBeFalse(); +}); + +test('playlist isActiveNow handles normal time ranges correctly', function (): void { + $playlist = Playlist::factory()->create([ + 'is_active' => true, + 'active_from' => '09:00', + 'active_until' => '17:00', + 'weekdays' => null, + ]); + + // Test at 10:00 AM - should be active + Carbon::setTestNow(Carbon::create(2024, 1, 1, 10, 0, 0)); + expect($playlist->isActiveNow())->toBeTrue(); + + // Test at 2:00 AM - should NOT be active + Carbon::setTestNow(Carbon::create(2024, 1, 1, 2, 0, 0)); + expect($playlist->isActiveNow())->toBeFalse(); + + // Test at 8:00 PM - should NOT be active + 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 new file mode 100644 index 0000000..9a95379 --- /dev/null +++ b/tests/Feature/PluginArchiveTest.php @@ -0,0 +1,308 @@ +create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'trmnlp_id' => 'test-plugin-123', + 'data_stale_minutes' => 30, + 'data_strategy' => 'static', + 'markup_language' => 'liquid', + 'render_markup' => '
Hello {{ config.name }}
', + 'configuration_template' => [ + 'custom_fields' => [ + [ + 'keyname' => 'name', + 'field_type' => 'text', + 'default' => 'World', + ], + ], + ], + 'data_payload' => ['message' => 'Hello World'], + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); + expect($response->getFile()->getFilename())->toContain('test-plugin-123.zip'); +}); + +it('exports plugin with polling configuration', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Polling Plugin', + 'trmnlp_id' => 'polling-plugin-456', + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.example.com/data', + 'polling_verb' => 'post', + 'polling_header' => 'Authorization: Bearer token', + 'polling_body' => '{"key": "value"}', + 'markup_language' => 'blade', + 'render_markup' => '
Hello {{ $config["name"] }}
', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); +}); + +it('exports and imports plugin maintaining all data', function (): void { + $user = User::factory()->create(); + $originalPlugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Round Trip Plugin', + 'trmnlp_id' => 'round-trip-789', + 'data_stale_minutes' => 45, + 'data_strategy' => 'static', + 'markup_language' => 'liquid', + 'render_markup' => '
Hello {{ config.name }}!
', + 'configuration_template' => [ + 'custom_fields' => [ + [ + 'keyname' => 'name', + 'field_type' => 'text', + 'default' => 'Test User', + ], + [ + 'keyname' => 'color', + 'field_type' => 'select', + 'default' => 'blue', + 'options' => ['red', 'green', 'blue'], + ], + ], + ], + 'data_payload' => ['items' => [1, 2, 3]], + ]); + + // Export the plugin + $exporter = app(PluginExportService::class); + $exportResponse = $exporter->exportToZip($originalPlugin, $user); + + // Get the exported file path + $exportedFilePath = $exportResponse->getFile()->getPathname(); + + // Create an UploadedFile from the exported ZIP + $uploadedFile = new UploadedFile( + $exportedFilePath, + 'plugin_round-trip-789.zip', + 'application/zip', + null, + true + ); + + // Import the plugin back + $importer = app(PluginImportService::class); + $importedPlugin = $importer->importFromZip($uploadedFile, $user); + + // Verify the imported plugin has the same data + expect($importedPlugin->name)->toBe('Round Trip Plugin'); + expect($importedPlugin->trmnlp_id)->toBe('round-trip-789'); + expect($importedPlugin->data_stale_minutes)->toBe(45); + expect($importedPlugin->data_strategy)->toBe('static'); + expect($importedPlugin->markup_language)->toBe('liquid'); + expect($importedPlugin->render_markup)->toContain('Hello {{ config.name }}!'); + expect($importedPlugin->configuration_template['custom_fields'])->toHaveCount(2); + expect($importedPlugin->data_payload)->toBe(['items' => [1, 2, 3]]); +}); + +it('handles blade templates correctly', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Blade Plugin', + 'trmnlp_id' => 'blade-plugin-101', + 'markup_language' => 'blade', + 'render_markup' => '
Hello {{ $config["name"] }}!
', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); +}); + +it('removes wrapper div from exported markup', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Wrapped Plugin', + 'trmnlp_id' => 'wrapped-plugin-202', + 'markup_language' => 'liquid', + 'render_markup' => '
Hello World
', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); +}); + +it('converts polling headers correctly', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Header Plugin', + 'trmnlp_id' => 'header-plugin-303', + 'data_strategy' => 'polling', + 'polling_header' => 'Authorization: Bearer token', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); +}); + +it('api route returns zip file for authenticated user', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'API Test Plugin', + 'trmnlp_id' => 'api-test-404', + 'markup_language' => 'liquid', + 'render_markup' => '
API Test
', + ]); + + $response = $this->actingAs($user) + ->getJson("/api/plugin_settings/{$plugin->trmnlp_id}/archive"); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'application/zip'); + $response->assertHeader('Content-Disposition', 'attachment; filename=plugin_api-test-404.zip'); +}); + +it('api route returns 404 for non-existent plugin', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->getJson('/api/plugin_settings/non-existent-id/archive'); + + $response->assertStatus(404); +}); + +it('api route returns 401 for unauthenticated user', function (): void { + $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 { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user1->id, + 'trmnlp_id' => 'other-user-plugin', + ]); + + $response = $this->actingAs($user2) + ->getJson("/api/plugin_settings/{$plugin->trmnlp_id}/archive"); + + $response->assertStatus(404); +}); + +it('exports zip with files in root directory', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Root Directory Test', + 'trmnlp_id' => 'root-test-123', + 'markup_language' => 'liquid', + 'render_markup' => '
Test content
', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + $zipPath = $response->getFile()->getPathname(); + $zip = new ZipArchive(); + $zip->open($zipPath); + + // Check that files are in the root, not in src/ + expect($zip->locateName('settings.yml'))->not->toBeFalse(); + expect($zip->locateName('full.liquid'))->not->toBeFalse(); + expect($zip->locateName('src/settings.yml'))->toBeFalse(); + expect($zip->locateName('src/full.liquid'))->toBeFalse(); + + $zip->close(); +}); + +it('maintains correct yaml field order', function (): void { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'YAML Order Test', + 'trmnlp_id' => 'yaml-order-test', + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.example.com/data', + 'polling_verb' => 'post', + 'data_stale_minutes' => 30, + 'markup_language' => 'liquid', + 'render_markup' => '
Test
', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + $zipPath = $response->getFile()->getPathname(); + $zip = new ZipArchive(); + $zip->open($zipPath); + + // Extract and read the settings.yml file + $zip->extractTo(sys_get_temp_dir(), 'settings.yml'); + $yamlContent = file_get_contents(sys_get_temp_dir().'/settings.yml'); + $zip->close(); + + // Check that the YAML content has the expected field order + $expectedOrder = [ + 'name:', + 'no_screen_padding:', + 'dark_mode:', + 'strategy:', + 'static_data:', + 'polling_verb:', + 'polling_url:', + 'refresh_interval:', + 'id:', + 'custom_fields:', + ]; + + $lines = explode("\n", $yamlContent); + $fieldLines = []; + + foreach ($lines as $line) { + $line = mb_trim($line); + if (preg_match('/^([a-zA-Z_]+):/', $line, $matches)) { + $fieldLines[] = $matches[1].':'; + } + } + + // Verify that the fields appear in the expected order (allowing for missing optional fields) + $currentIndex = 0; + foreach ($expectedOrder as $expectedField) { + $foundIndex = array_search($expectedField, $fieldLines); + if ($foundIndex !== false) { + expect($foundIndex)->toBeGreaterThanOrEqual($currentIndex); + $currentIndex = $foundIndex; + } + } + + // Clean up + unlink(sys_get_temp_dir().'/settings.yml'); +}); diff --git a/tests/Feature/PluginDefaultValuesTest.php b/tests/Feature/PluginDefaultValuesTest.php new file mode 100644 index 0000000..353ad0c --- /dev/null +++ b/tests/Feature/PluginDefaultValuesTest.php @@ -0,0 +1,122 @@ +create(); + + // Test the functionality directly by creating a plugin with the expected configuration + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'reading_days', + 'field_type' => 'string', + 'name' => 'Reading Days', + 'description' => 'Select days of the week to read', + 'default' => 'Monday,Friday,Saturday,Sunday', + ], + [ + 'keyname' => 'refresh_interval', + 'field_type' => 'number', + 'name' => 'Refresh Interval', + 'description' => 'How often to refresh data', + 'default' => 15, + ], + [ + 'keyname' => 'timezone', + 'field_type' => 'time_zone', + 'name' => 'Timezone', + 'description' => 'Select your timezone', + // No default value + ], + ], + ]; + + // Extract default values from custom_fields and populate configuration + $configuration = []; + if (isset($configurationTemplate['custom_fields']) && is_array($configurationTemplate['custom_fields'])) { + foreach ($configurationTemplate['custom_fields'] as $field) { + if (isset($field['keyname']) && isset($field['default'])) { + $configuration[$field['keyname']] = $field['default']; + } + } + } + + // Create the plugin directly + $plugin = Plugin::create([ + 'uuid' => Illuminate\Support\Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin with Defaults', + 'data_stale_minutes' => 30, + 'data_strategy' => 'static', + 'configuration_template' => $configurationTemplate, + 'configuration' => $configuration, + ]); + + // Assert the plugin was created with correct configuration + expect($plugin)->not->toBeNull(); + expect($plugin->configuration)->toBeArray(); + expect($plugin->configuration)->toHaveKey('reading_days'); + expect($plugin->configuration)->toHaveKey('refresh_interval'); + expect($plugin->configuration)->not->toHaveKey('timezone'); + + expect($plugin->getConfiguration('reading_days'))->toBe('Monday,Friday,Saturday,Sunday'); + expect($plugin->getConfiguration('refresh_interval'))->toBe(15); + expect($plugin->getConfiguration('timezone'))->toBeNull(); + + // Verify configuration template was stored correctly + expect($plugin->configuration_template)->toBeArray(); + expect($plugin->configuration_template['custom_fields'])->toHaveCount(3); +}); + +test('plugin import handles custom_fields without default values', function (): void { + // Create a user + $user = User::factory()->create(); + + // Test the functionality directly by creating a plugin with no default values + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'timezone', + 'field_type' => 'time_zone', + 'name' => 'Timezone', + 'description' => 'Select your timezone', + ], + ], + ]; + + // Extract default values from custom_fields and populate configuration + $configuration = []; + if (isset($configurationTemplate['custom_fields']) && is_array($configurationTemplate['custom_fields'])) { + foreach ($configurationTemplate['custom_fields'] as $field) { + if (isset($field['keyname']) && isset($field['default'])) { + $configuration[$field['keyname']] = $field['default']; + } + } + } + + // Create the plugin directly + $plugin = Plugin::create([ + 'uuid' => Illuminate\Support\Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin No Defaults', + 'data_stale_minutes' => 30, + 'data_strategy' => 'static', + 'configuration_template' => $configurationTemplate, + 'configuration' => $configuration, + ]); + + // Assert the plugin was created with empty configuration + expect($plugin)->not->toBeNull(); + expect($plugin->configuration)->toBeArray(); + expect($plugin->configuration)->toBeEmpty(); + + // Verify configuration template was stored correctly + expect($plugin->configuration_template)->toBeArray(); + expect($plugin->configuration_template['custom_fields'])->toHaveCount(1); +}); diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php new file mode 100644 index 0000000..f3ef1fa --- /dev/null +++ b/tests/Feature/PluginImportTest.php @@ -0,0 +1,589 @@ +create(); + + // Create a mock ZIP file with the required structure + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + 'src/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.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') + ->and($plugin->data_stale_minutes)->toBe(30) + ->and($plugin->data_strategy)->toBe('static') + ->and($plugin->markup_language)->toBe('liquid') + ->and($plugin->configuration_template)->toHaveKey('custom_fields') + ->and($plugin->configuration)->toHaveKey('api_key') + ->and($plugin->configuration['api_key'])->toBe('default-api-key'); +}); + +it('imports plugin with shared.liquid file', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + 'src/full.liquid' => getValidFullLiquid(), + 'src/shared.liquid' => '{% comment %}Shared styles{% endcomment %}', + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin->render_markup)->toContain('{% comment %}Shared styles{% endcomment %}') + ->and($plugin->render_markup)->toContain('
'); +}); + +it('imports plugin with files in root directory', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'settings.yml' => getValidSettingsYaml(), + 'full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->name)->toBe('Test Plugin'); +}); + +it('throws exception for invalid zip file', function (): void { + $user = User::factory()->create(); + + $zipFile = UploadedFile::fake()->createWithContent('invalid.zip', 'not a zip file'); + + $pluginImportService = new PluginImportService(); + expect(fn (): Plugin => $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 { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + // Missing all template files + ]); + + $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.'); +}); + +it('sets default values when settings are missing', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => "name: Minimal Plugin\n", + 'src/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin->name)->toBe('Minimal Plugin') + ->and($plugin->data_stale_minutes)->toBe(15) // default value + ->and($plugin->data_strategy)->toBe('static') // default value + ->and($plugin->polling_verb)->toBe('get'); // default value +}); + +it('handles blade markup language correctly', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + 'src/full.blade.php' => '
Blade template
', + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); + + $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('
'); +}); + +// Helper methods +function createMockZipFile(array $files): string +{ + $zip = new ZipArchive(); + + $tempFileName = 'test_zip_'.uniqid().'.zip'; + $tempFile = Storage::path($tempFileName); + + $zip->open($tempFile, ZipArchive::CREATE); + + foreach ($files as $path => $content) { + $zip->addFromString($path, $content); + } + + $zip->close(); + + $content = file_get_contents($tempFile); + + Storage::delete($tempFileName); + + return $content; +} + +function getValidSettingsYaml(): string +{ + return <<<'YAML' +name: Test Plugin +refresh_interval: 30 +strategy: static +polling_verb: get +static_data: '{"test": "data"}' +custom_fields: + - keyname: api_key + field_type: text + default: default-api-key + label: API Key +YAML; +} + +function getValidFullLiquid(): string +{ + return <<<'LIQUID' +
+

{{ data.title }}

+

{{ data.description }}

+
+LIQUID; +} diff --git a/tests/Feature/PluginInlineTemplatesTest.php b/tests/Feature/PluginInlineTemplatesTest.php new file mode 100644 index 0000000..76b29d7 --- /dev/null +++ b/tests/Feature/PluginInlineTemplatesTest.php @@ -0,0 +1,240 @@ +create([ + 'name' => 'Test Plugin', + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{% assign min = 1 %} +{% assign max = facts | size %} +{% assign diff = max | minus: min %} +{% assign randomNumber = "now" | date: "u" | modulo: diff | plus: min %} + +{% template session %} +
+
+
+
+
+ {{ facts[randomNumber] }} +
+
+
+
+
+{% endtemplate %} + +{% template title_bar %} +
+ {{ trmnl.plugin_settings.instance_name }} + {{ instance }} +
+{% endtemplate %} + +
+{% render "session", + trmnl: trmnl, + facts: facts, + randomNumber: randomNumber, + size_mod: "" +%} + +{% render "title_bar", + trmnl: trmnl, + instance: "Please try to enjoy each fact equally." +%} +
+LIQUID + , + 'data_payload' => [ + 'facts' => ['Fact 1', 'Fact 2', 'Fact 3'], + ], + ]); + + $result = $plugin->render('full'); + + // 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') + ); + $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 { + $plugin = Plugin::factory()->create([ + 'name' => 'Test Plugin', + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{% assign min = 1 %} +{% assign max = facts | size %} +{% assign diff = max | minus: min %} +{% assign randomNumber = "now" | date: "u" | modulo: diff | plus: min %} + +{% template session %} +
+
+
+
+
+ {{ facts[randomNumber] }} +
+
+
+
+
+{% endtemplate %} + +{% template title_bar %} +
+ + {{ trmnl.plugin_settings.instance_name }} + {{ instance }} +
+{% endtemplate %} + +
+{% render "session" with + trmnl: trmnl, + facts: facts, + randomNumber: randomNumber, + size_mod: "" +%} + +{% render "title_bar" with + trmnl: trmnl, + instance: "Please try to enjoy each fact equally." +%} +
+LIQUID + , + 'data_payload' => [ + 'facts' => ['Fact 1', 'Fact 2', 'Fact 3'], + ], + ]); + + $result = $plugin->render('full'); + + // 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') + ); + $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 { + $plugin = Plugin::factory()->create([ + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{% template simple %} +
+

{{ title }}

+

{{ content }}

+
+{% endtemplate %} + +{% render "simple", + title: "Hello World", + content: "This is a test" +%} +LIQUID + , + ]); + + $result = $plugin->render('full'); + + $this->assertStringContainsString('Hello World', $result); + $this->assertStringContainsString('This is a test', $result); + $this->assertStringContainsString('class="simple"', $result); +}); + +test('renders plugin with liquid filter find_by', function (): void { + $plugin = Plugin::factory()->create([ + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{% template user_info %} +
+

{{ user.name }}

+

Age: {{ user.age }}

+
+{% endtemplate %} + +{% assign found_user = collection | find_by: 'name', 'Ryan' %} +{% render "user_info", user: found_user %} +LIQUID + , + 'data_payload' => [ + 'collection' => [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ], + ], + ]); + + $result = $plugin->render('full'); + + // Should render the user info for Ryan + $this->assertStringContainsString('Ryan', $result); + $this->assertStringContainsString('Age: 35', $result); + $this->assertStringContainsString('class="user"', $result); +}); + +test('renders plugin with liquid filter find_by and fallback', function (): void { + $plugin = Plugin::factory()->create([ + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{{ collection | find_by: 'name', 'ronak', 'Not Found' }} +LIQUID + , + 'data_payload' => [ + 'collection' => [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ], + ], + ]); + + $result = $plugin->render('full'); + + // Should return the fallback value + $this->assertStringContainsString('Not Found', $result); +}); + +test('renders plugin with liquid filter group_by', function (): void { + $plugin = Plugin::factory()->create([ + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{{ collection | group_by: 'age' | json }} +LIQUID + , + 'data_payload' => [ + 'collection' => [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ], + ], + ]); + + $result = $plugin->render('full'); + + // Should output JSON representation of grouped data + $this->assertStringContainsString('"35":[{"name":"Ryan","age":35}]', $result); + $this->assertStringContainsString('"29":[{"name":"Sara","age":29},{"name":"Jimbob","age":29}]', $result); +}); diff --git a/tests/Feature/PluginLiquidFilterTest.php b/tests/Feature/PluginLiquidFilterTest.php new file mode 100644 index 0000000..e6272c7 --- /dev/null +++ b/tests/Feature/PluginLiquidFilterTest.php @@ -0,0 +1,176 @@ +create([ + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{% liquid +assign json_string = '[{"t":"2025-08-26 01:48","v":"4.624","type":"H"},{"t":"2025-08-26 08:04","v":"0.333","type":"L"}]' +assign collection = json_string | parse_json +%} + +{% assign tides_h = collection | where: "type", "H" %} + +{% for tide in tides_h %} + {{ tide | json }} +{%- endfor %} +LIQUID + ]); + + $result = $plugin->render('full'); + + // Should output the high tide data + $this->assertStringContainsString('"t":"2025-08-26 01:48"', $result); + $this->assertStringContainsString('"v":"4.624"', $result); + $this->assertStringContainsString('"type":"H"', $result); + // Should not contain the low tide data + $this->assertStringNotContainsString('"type":"L"', $result); +}); + +test('where filter works directly in for loop with preprocessing', function (): void { + $plugin = Plugin::factory()->create([ + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{% liquid +assign json_string = '[{"t":"2025-08-26 01:48","v":"4.624","type":"H"},{"t":"2025-08-26 08:04","v":"0.333","type":"L"}]' +assign collection = json_string | parse_json +%} + +{% for tide in collection | where: "type", "H" %} + {{ tide | json }} +{%- endfor %} +LIQUID + ]); + + $result = $plugin->render('full'); + + // Should output the high tide data + $this->assertStringContainsString('"t":"2025-08-26 01:48"', $result); + $this->assertStringContainsString('"v":"4.624"', $result); + $this->assertStringContainsString('"type":"H"', $result); + // Should not contain the low tide data + $this->assertStringNotContainsString('"type":"L"', $result); +}); + +test('where filter works directly in for loop with multiple matches', function (): void { + $plugin = Plugin::factory()->create([ + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{% liquid +assign json_string = '[{"t":"2025-08-26 01:48","v":"4.624","type":"H"},{"t":"2025-08-26 08:04","v":"0.333","type":"L"},{"t":"2025-08-26 14:30","v":"4.8","type":"H"}]' +assign collection = json_string | parse_json +%} + +{% for tide in collection | where: "type", "H" %} + {{ tide | json }} +{%- endfor %} +LIQUID + ]); + + $result = $plugin->render('full'); + + // Should output both high tide data entries + $this->assertStringContainsString('"t":"2025-08-26 01:48"', $result); + $this->assertStringContainsString('"t":"2025-08-26 14:30"', $result); + $this->assertStringContainsString('"v":"4.624"', $result); + $this->assertStringContainsString('"v":"4.8"', $result); + // 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 new file mode 100644 index 0000000..51e1b76 --- /dev/null +++ b/tests/Feature/PluginRequiredConfigurationTest.php @@ -0,0 +1,346 @@ +create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'api_key', + 'field_type' => 'string', + 'name' => 'API Key', + 'description' => 'Your API key', + // Not marked as optional, so it's required + ], + [ + 'keyname' => 'timezone', + 'field_type' => 'time_zone', + 'name' => 'Timezone', + 'description' => 'Select your timezone', + 'optional' => true, // Marked as optional + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'timezone' => 'UTC', // Only timezone is set, api_key is missing + ], + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); +}); + +test('hasMissingRequiredConfigurationFields returns false when all required fields are set', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'api_key', + 'field_type' => 'string', + 'name' => 'API Key', + 'description' => 'Your API key', + // Not marked as optional, so it's required + ], + [ + 'keyname' => 'timezone', + 'field_type' => 'time_zone', + 'name' => 'Timezone', + 'description' => 'Select your timezone', + 'optional' => true, // Marked as optional + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'api_key' => 'test-api-key', // Required field is set + 'timezone' => 'UTC', + ], + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); +}); + +test('hasMissingRequiredConfigurationFields returns false when no custom fields exist', function (): void { + $user = User::factory()->create(); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => [], + 'configuration' => [], + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); +}); + +test('hasMissingRequiredConfigurationFields returns true when explicitly required field is null', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'api_key', + 'field_type' => 'string', + 'name' => 'API Key', + 'description' => 'Your API key', + // Not marked as optional, so it's required + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'api_key' => null, // Explicitly set to null + ], + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); +}); + +test('hasMissingRequiredConfigurationFields returns true when required field is empty string', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'api_key', + 'field_type' => 'string', + 'name' => 'API Key', + 'description' => 'Your API key', + // Not marked as optional, so it's required + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'api_key' => '', // Empty string + ], + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); +}); + +test('hasMissingRequiredConfigurationFields returns true when required array field is empty', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'selected_items', + 'field_type' => 'select', + 'name' => 'Selected Items', + 'description' => 'Select items', + 'multiple' => true, + // Not marked as optional, so it's required + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'selected_items' => [], // Empty array + ], + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); +}); + +test('hasMissingRequiredConfigurationFields returns false when author_bio field is present but other required field is set', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'author_bio', + 'name' => 'About This Plugin', + 'field_type' => 'author_bio', + ], + [ + 'keyname' => 'plugin_field', + 'name' => 'Field Name', + 'field_type' => 'string', + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'plugin_field' => 'set', // Required field is set + ], + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); +}); + +test('hasMissingRequiredConfigurationFields returns false when field has default value', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'api_key', + 'field_type' => 'string', + 'name' => 'API Key', + 'description' => 'Your API key', + 'default' => 'default-api-key', // Has default value + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [], // Empty configuration, but field has default + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); +}); + +test('hasMissingRequiredConfigurationFields returns true when required xhrSelect field is missing', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'team', + 'field_type' => 'xhrSelect', + 'name' => 'Baseball Team', + 'description' => 'Select your team', + 'endpoint' => 'https://usetrmnl.com/custom_plugin_example_xhr_select.json', + // 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 xhrSelect field is set', function (): void { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'team', + 'field_type' => 'xhrSelect', + 'name' => 'Baseball Team', + 'description' => 'Select your team', + 'endpoint' => 'https://usetrmnl.com/custom_plugin_example_xhr_select.json', + // Not marked as optional, so it's required + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'team' => '123', // Required field is set + ], + ]); + + 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 new file mode 100644 index 0000000..2a75c9e --- /dev/null +++ b/tests/Feature/PluginResponseTest.php @@ -0,0 +1,287 @@ + 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 new file mode 100644 index 0000000..22d1d54 --- /dev/null +++ b/tests/Feature/PluginWebhookTest.php @@ -0,0 +1,69 @@ +create([ + 'data_strategy' => 'webhook', + 'data_payload' => ['old' => 'data'], + ]); + + // Make request to update plugin data + $response = $this->postJson("/api/custom_plugins/{$plugin->uuid}", [ + 'merge_variables' => ['new' => 'data'], + ]); + + // Assert response + $response->assertOk() + ->assertJson(['message' => 'Data updated successfully']); + + // Assert plugin was updated + $this->assertDatabaseHas('plugins', [ + 'id' => $plugin->id, + 'data_payload' => json_encode(['new' => 'data']), + ]); +}); + +test('webhook returns 400 for non-webhook strategy plugins', function (): void { + // Create a plugin with non-webhook strategy + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'data_payload' => ['old' => 'data'], + ]); + + // Make request to update plugin data + $response = $this->postJson("/api/custom_plugins/{$plugin->uuid}", [ + 'merge_variables' => ['new' => 'data'], + ]); + + // Assert response + $response->assertStatus(400) + ->assertJson(['error' => 'Plugin does not use webhook strategy']); +}); + +test('webhook returns 400 when merge_variables is missing', function (): void { + // Create a plugin with webhook strategy + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'webhook', + 'data_payload' => ['old' => 'data'], + ]); + + // Make request without merge_variables + $response = $this->postJson("/api/custom_plugins/{$plugin->uuid}", []); + + // Assert response + $response->assertStatus(400) + ->assertJson(['error' => 'Request must contain merge_variables key']); +}); + +test('webhook returns 404 for non-existent plugin', function (): void { + // Make request with non-existent plugin UUID + $response = $this->postJson('/api/custom_plugins/'.Str::uuid(), [ + 'merge_variables' => ['new' => 'data'], + ]); + + // Assert response + $response->assertNotFound(); +}); diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php index d0c32c5..0e33955 100644 --- a/tests/Feature/Settings/PasswordUpdateTest.php +++ b/tests/Feature/Settings/PasswordUpdateTest.php @@ -4,9 +4,9 @@ use App\Models\User; use Illuminate\Support\Facades\Hash; use Livewire\Volt\Volt; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('password can be updated', function () { +test('password can be updated', function (): void { $user = User::factory()->create([ 'password' => Hash::make('password'), ]); @@ -24,7 +24,7 @@ test('password can be updated', function () { expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue(); }); -test('correct password must be provided to update password', function () { +test('correct password must be provided to update password', function (): void { $user = User::factory()->create([ 'password' => Hash::make('password'), ]); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index 6628ccc..cbf424c 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -3,15 +3,15 @@ use App\Models\User; use Livewire\Volt\Volt; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(Illuminate\Foundation\Testing\RefreshDatabase::class); -test('profile page is displayed', function () { +test('profile page is displayed', function (): void { $this->actingAs($user = User::factory()->create()); $this->get('/settings/profile')->assertOk(); }); -test('profile information can be updated', function () { +test('profile information can be updated', function (): void { $user = User::factory()->create(); $this->actingAs($user); @@ -30,7 +30,7 @@ test('profile information can be updated', function () { expect($user->email_verified_at)->toBeNull(); }); -test('email verification status is unchanged when email address is unchanged', function () { +test('email verification status is unchanged when email address is unchanged', function (): void { $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 () { +test('user can delete their account', function (): void { $user = User::factory()->create(); $this->actingAs($user); @@ -62,7 +62,7 @@ test('user can delete their account', function () { expect(auth()->check())->toBeFalse(); }); -test('correct password must be provided to delete account', function () { +test('correct password must be provided to delete account', function (): void { $user = User::factory()->create(); $this->actingAs($user); diff --git a/tests/Feature/TransformDefaultImagesTest.php b/tests/Feature/TransformDefaultImagesTest.php new file mode 100644 index 0000000..2ea995f --- /dev/null +++ b/tests/Feature/TransformDefaultImagesTest.php @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..a80c63a --- /dev/null +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -0,0 +1,286 @@ + 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 new file mode 100644 index 0000000..376a4a6 --- /dev/null +++ b/tests/Feature/Volt/DevicePalettesTest.php @@ -0,0 +1,575 @@ +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/Pest.php b/tests/Pest.php index b93193e..1c47402 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,9 +13,20 @@ pest()->extend(Tests\TestCase::class) ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) - ->in('Feature'); + ->in('Feature', 'Unit'); + +arch() + ->preset() + ->laravel() + ->ignoring([ + App\Http\Controllers\Auth\OidcController::class, + App\Models\DeviceModel::class, + ]); + +arch() + ->expect('App') + ->not->toUse(['die', 'dd', 'dump', 'ray']); -registerSpatiePestHelpers(); /* |-------------------------------------------------------------------------- | Expectations diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php index 44a4f33..963bc0c 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 new file mode 100644 index 0000000..1200b6f --- /dev/null +++ b/tests/Unit/Liquid/Filters/DataTest.php @@ -0,0 +1,497 @@ + 'bar', 'baz' => 'qux']; + + expect($filter->json($array))->toBe('{"foo":"bar","baz":"qux"}'); +}); + +test('json filter converts objects to JSON', function (): void { + $filter = new Data(); + $object = new stdClass(); + $object->foo = 'bar'; + $object->baz = 'qux'; + + expect($filter->json($object))->toBe('{"foo":"bar","baz":"qux"}'); +}); + +test('json filter handles nested structures', function (): void { + $filter = new Data(); + $nested = [ + 'foo' => 'bar', + 'nested' => [ + 'baz' => 'qux', + 'items' => [1, 2, 3], + ], + ]; + + expect($filter->json($nested))->toBe('{"foo":"bar","nested":{"baz":"qux","items":[1,2,3]}}'); +}); + +test('json filter handles scalar values', function (): void { + $filter = new Data(); + + expect($filter->json('string'))->toBe('"string"'); + expect($filter->json(123))->toBe('123'); + expect($filter->json(true))->toBe('true'); + expect($filter->json(null))->toBe('null'); +}); + +test('json filter preserves unicode characters', function (): void { + $filter = new Data(); + $data = ['message' => 'Hello, δΈ–η•Œ']; + + expect($filter->json($data))->toBe('{"message":"Hello, δΈ–η•Œ"}'); +}); + +test('json filter does not escape slashes', function (): void { + $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 { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ]; + + $result = $filter->find_by($collection, 'name', 'Ryan'); + expect($result)->toBe(['name' => 'Ryan', 'age' => 35]); +}); + +test('find_by filter returns null when no match found', function (): void { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ]; + + $result = $filter->find_by($collection, 'name', 'ronak'); + expect($result)->toBeNull(); +}); + +test('find_by filter returns fallback when no match found', function (): void { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ]; + + $result = $filter->find_by($collection, 'name', 'ronak', 'Not Found'); + expect($result)->toBe('Not Found'); +}); + +test('find_by filter finds by age', function (): void { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ]; + + $result = $filter->find_by($collection, 'age', 29); + expect($result)->toBe(['name' => 'Sara', 'age' => 29]); +}); + +test('find_by filter handles empty collection', function (): void { + $filter = new Data(); + $collection = []; + + $result = $filter->find_by($collection, 'name', 'Ryan'); + expect($result)->toBeNull(); +}); + +test('find_by filter handles collection with non-array items', function (): void { + $filter = new Data(); + $collection = [ + 'not an array', + ['name' => 'Ryan', 'age' => 35], + null, + ]; + + $result = $filter->find_by($collection, 'name', 'Ryan'); + expect($result)->toBe(['name' => 'Ryan', 'age' => 35]); +}); + +test('find_by filter handles items without the specified key', function (): void { + $filter = new Data(); + $collection = [ + ['age' => 35], + ['name' => 'Ryan', 'age' => 35], + ['title' => 'Developer'], + ]; + + $result = $filter->find_by($collection, 'name', 'Ryan'); + expect($result)->toBe(['name' => 'Ryan', 'age' => 35]); +}); + +test('group_by filter groups collection by age', function (): void { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ]; + + $result = $filter->group_by($collection, 'age'); + + expect($result)->toBe([ + 35 => [['name' => 'Ryan', 'age' => 35]], + 29 => [ + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ], + ]); +}); + +test('group_by filter groups collection by name', function (): void { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Ryan', 'age' => 30], + ]; + + $result = $filter->group_by($collection, 'name'); + + expect($result)->toBe([ + 'Ryan' => [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Ryan', 'age' => 30], + ], + 'Sara' => [['name' => 'Sara', 'age' => 29]], + ]); +}); + +test('group_by filter handles empty collection', function (): void { + $filter = new Data(); + $collection = []; + + $result = $filter->group_by($collection, 'age'); + expect($result)->toBe([]); +}); + +test('group_by filter handles collection with non-array items', function (): void { + $filter = new Data(); + $collection = [ + 'not an array', + ['name' => 'Ryan', 'age' => 35], + null, + ['name' => 'Sara', 'age' => 29], + ]; + + $result = $filter->group_by($collection, 'age'); + + expect($result)->toBe([ + 35 => [['name' => 'Ryan', 'age' => 35]], + 29 => [['name' => 'Sara', 'age' => 29]], + ]); +}); + +test('group_by filter handles items without the specified key', function (): void { + $filter = new Data(); + $collection = [ + ['age' => 35], + ['name' => 'Ryan', 'age' => 35], + ['title' => 'Developer'], + ['name' => 'Sara', 'age' => 29], + ]; + + $result = $filter->group_by($collection, 'age'); + + expect($result)->toBe([ + 35 => [ + ['age' => 35], + ['name' => 'Ryan', 'age' => 35], + ], + 29 => [['name' => 'Sara', 'age' => 29]], + ]); +}); + +test('group_by filter handles mixed data types as keys', function (): void { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'active' => true], + ['name' => 'Sara', 'active' => false], + ['name' => 'Jimbob', 'active' => true], + ['name' => 'Alice', 'active' => null], + ]; + + $result = $filter->group_by($collection, 'active'); + + expect($result)->toBe([ + 1 => [ // PHP converts true to 1 + ['name' => 'Ryan', 'active' => true], + ['name' => 'Jimbob', 'active' => true], + ], + 0 => [['name' => 'Sara', 'active' => false]], // PHP converts false to 0 + '' => [['name' => 'Alice', 'active' => null]], // PHP converts null keys to empty string + ]); +}); + +test('sample filter returns a random element from array', function (): void { + $filter = new Data(); + $array = ['1', '2', '3', '4', '5']; + + $result = $filter->sample($array); + expect($result)->toBeIn($array); +}); + +test('sample filter returns a random element from string array', function (): void { + $filter = new Data(); + $array = ['cat', 'dog']; + + $result = $filter->sample($array); + expect($result)->toBeIn($array); +}); + +test('sample filter returns null for empty array', function (): void { + $filter = new Data(); + $array = []; + + $result = $filter->sample($array); + expect($result)->toBeNull(); +}); + +test('sample filter returns the only element from single element array', function (): void { + $filter = new Data(); + $array = ['single']; + + $result = $filter->sample($array); + expect($result)->toBe('single'); +}); + +test('sample filter works with mixed data types', function (): void { + $filter = new Data(); + $array = [1, 'string', true, null, ['nested']]; + + $result = $filter->sample($array); + expect($result)->toBeIn($array); +}); + +test('parse_json filter parses JSON string to array', function (): void { + $filter = new Data(); + $jsonString = '[{"a":1,"b":"c"},"d"]'; + + $result = $filter->parse_json($jsonString); + expect($result)->toBe([['a' => 1, 'b' => 'c'], 'd']); +}); + +test('parse_json filter parses simple JSON object', function (): void { + $filter = new Data(); + $jsonString = '{"name":"John","age":30,"city":"New York"}'; + + $result = $filter->parse_json($jsonString); + expect($result)->toBe(['name' => 'John', 'age' => 30, 'city' => 'New York']); +}); + +test('parse_json filter parses JSON array', function (): void { + $filter = new Data(); + $jsonString = '["apple","banana","cherry"]'; + + $result = $filter->parse_json($jsonString); + expect($result)->toBe(['apple', 'banana', 'cherry']); +}); + +test('parse_json filter parses nested JSON structure', function (): void { + $filter = new Data(); + $jsonString = '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}],"total":2}'; + + $result = $filter->parse_json($jsonString); + expect($result)->toBe([ + 'users' => [ + ['id' => 1, 'name' => 'Alice'], + ['id' => 2, 'name' => 'Bob'], + ], + 'total' => 2, + ]); +}); + +test('parse_json filter handles primitive values', function (): void { + $filter = new Data(); + + expect($filter->parse_json('"hello"'))->toBe('hello'); + expect($filter->parse_json('123'))->toBe(123); + expect($filter->parse_json('true'))->toBe(true); + 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 new file mode 100644 index 0000000..7de8949 --- /dev/null +++ b/tests/Unit/Liquid/Filters/DateTest.php @@ -0,0 +1,94 @@ +subDays(3)->toDateString(); + + expect($filter->days_ago(3))->toBe($threeDaysAgo); +}); + +test('days_ago filter handles string input', function (): void { + $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 { + $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 { + $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 new file mode 100644 index 0000000..3129b1e --- /dev/null +++ b/tests/Unit/Liquid/Filters/LocalizationTest.php @@ -0,0 +1,137 @@ +l_date($date); + + // Default format is 'Y-m-d', which should output something like '2025-01-11' + // The exact output might vary depending on the locale, but it should contain the year, month, and day + expect($result)->toContain('2025'); + expect($result)->toContain('01'); + expect($result)->toContain('11'); +}); + +test('l_date formats date with custom format', function (): void { + $filter = new Localization(); + $date = '2025-01-11'; + + $result = $filter->l_date($date, '%y %b'); + + // Format '%y %b' should output something like '25 Jan' + // The month name might vary depending on the locale + expect($result)->toContain('25'); + // We can't check for 'Jan' specifically as it might be localized +}); + +test('l_date handles DateTime objects', function (): void { + $filter = new Localization(); + $date = new DateTimeImmutable('2025-01-11'); + + $result = $filter->l_date($date, 'Y-m-d'); + + expect($result)->toContain('2025-01-11'); +}); + +test('l_word translates common words', function (): void { + $filter = new Localization(); + + expect($filter->l_word('today', 'de'))->toBe('heute'); +}); + +test('l_word returns original word if no translation exists', function (): void { + $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 { + $filter = new Localization(); + + expect($filter->l_word('TODAY', 'de'))->toBe('heute'); +}); + +test('l_word returns original word for unknown locales', function (): void { + $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 new file mode 100644 index 0000000..42deffb --- /dev/null +++ b/tests/Unit/Liquid/Filters/NumbersTest.php @@ -0,0 +1,138 @@ +number_with_delimiter(1234))->toBe('1,234'); + expect($filter->number_with_delimiter(1000000))->toBe('1,000,000'); + expect($filter->number_with_delimiter(0))->toBe('0'); +}); + +test('number_with_delimiter handles custom delimiters', function (): void { + $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 { + $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 { + $filter = new Numbers(); + + expect($filter->number_to_currency(1234))->toBe('$1,234'); + expect($filter->number_to_currency(1234.5))->toBe('$1,234.50'); + expect($filter->number_to_currency(0))->toBe('$0'); +}); + +test('number_to_currency handles custom currency symbols', function (): void { + $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 { + $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'); +}); diff --git a/tests/Unit/Liquid/Filters/StringMarkupTest.php b/tests/Unit/Liquid/Filters/StringMarkupTest.php new file mode 100644 index 0000000..bfd1a07 --- /dev/null +++ b/tests/Unit/Liquid/Filters/StringMarkupTest.php @@ -0,0 +1,170 @@ +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 { + $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 { + $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 { + $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 { + $filter = new StringMarkup(); + $markdown = 'This is *italic* and **bold**.'; + + // The exact HTML output might vary depending on the Parsedown implementation + // So we'll check for the presence of HTML tags rather than the exact output + $result = $filter->markdown_to_html($markdown); + + expect($result)->toContain('italic'); + expect($result)->toContain('bold'); +}); + +test('markdown_to_html converts links correctly', function (): void { + $filter = new StringMarkup(); + $markdown = 'This is [a link](https://example.com).'; + + $result = $filter->markdown_to_html($markdown); + + expect($result)->toContain('a link'); +}); + +test('markdown_to_html handles fallback when Parsedown is not available', function (): void { + // Create a mock that simulates Parsedown not being available + $filter = new class extends StringMarkup + { + public function markdown_to_html(string $markdown): string + { + // Force the fallback path + return nl2br(htmlspecialchars($markdown)); + } + }; + + $markdown = 'This is *italic* and [a link](https://example.com).'; + $result = $filter->markdown_to_html($markdown); + + expect($result)->toBe('This is *italic* and [a link](https://example.com).'); +}); + +test('strip_html removes HTML tags', function (): void { + $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 { + $filter = new StringMarkup(); + $html = '
Hello, world!
'; + + expect($filter->strip_html($html))->toBe('Hello, world!'); +}); + +test('strip_html handles nested tags', function (): void { + $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 new file mode 100644 index 0000000..76840e1 --- /dev/null +++ b/tests/Unit/Liquid/Filters/UniquenessTest.php @@ -0,0 +1,13 @@ +append_random('chart-'); + + // Check that the result starts with the prefix + expect($result)->toStartWith('chart-'); + // Check that the result is longer than just the prefix (has random part) + expect(mb_strlen($result))->toBe(mb_strlen('chart-') + 4); +}); diff --git a/tests/Unit/Liquid/InlineTemplatesTest.php b/tests/Unit/Liquid/InlineTemplatesTest.php new file mode 100644 index 0000000..e3685b8 --- /dev/null +++ b/tests/Unit/Liquid/InlineTemplatesTest.php @@ -0,0 +1,342 @@ +fileSystem = new InlineTemplatesFileSystem(); + $this->environment = new Environment( + fileSystem: $this->fileSystem, + extensions: [new StandardExtension()] + ); + $this->environment->tagRegistry->register(TemplateTag::class); + $this->environment->tagRegistry->register(RenderTag::class); + $this->environment->filterRegistry->register(Data::class); + $this->environment->filterRegistry->register(Date::class); + $this->environment->filterRegistry->register(Localization::class); + $this->environment->filterRegistry->register(Numbers::class); + $this->environment->filterRegistry->register(StringMarkup::class); + $this->environment->filterRegistry->register(Uniqueness::class); + } + + public function test_template_tag_registers_template(): void + { + $template = $this->environment->parseString(<<<'LIQUID' +{% template session %} +
    +
    +
    +
    +
    + {{ facts[randomNumber] }} +
    +
    +
    +
    +
    +{% endtemplate %} +LIQUID + ); + + $context = $this->environment->newRenderContext( + data: [ + 'facts' => ['Fact 1', 'Fact 2', 'Fact 3'], + 'randomNumber' => 1, + 'size_mod' => '--large', + ] + ); + + $result = $template->render($context); + + // Template tag should not output anything + $this->assertEquals('', $result); + + // Template should be registered in the file system + $this->assertTrue($this->fileSystem->hasTemplate('session')); + + $registeredTemplate = $this->fileSystem->readTemplateFile('session'); + $this->assertStringContainsString('{{ facts[randomNumber] }}', $registeredTemplate); + $this->assertStringContainsString('{{ size_mod }}', $registeredTemplate); + } + + public function test_template_tag_with_render_tag(): void + { + $template = $this->environment->parseString(<<<'LIQUID' +{% template session %} +
    +
    +
    +
    +
    + {{ facts[randomNumber] }} +
    +
    +
    +
    +
    +{% endtemplate %} + +{% render "session", + trmnl: trmnl, + facts: facts, + randomNumber: randomNumber, + size_mod: "" +%} +LIQUID + ); + + $context = $this->environment->newRenderContext( + data: [ + 'facts' => ['Fact 1', 'Fact 2', 'Fact 3'], + 'randomNumber' => 1, + 'trmnl' => ['plugin_settings' => ['instance_name' => 'Test']], + ] + ); + + $result = $template->render($context); + + // Should render the template content + $this->assertStringContainsString('Fact 2', $result); // facts[1] + $this->assertStringContainsString('class="layout"', $result); + $this->assertStringContainsString('class="value text--center"', $result); + } + + public function test_apply_liquid_replacements_converts_with_syntax(): void + { + // This test simulates the applyLiquidReplacements method from the Plugin model + $originalLiquid = <<<'LIQUID' +{% template session %} +
    +
    +
    +
    +
    + {{ facts[randomNumber] }} +
    +
    +
    +
    +
    +{% endtemplate %} + +{% render "session" with + trmnl: trmnl, + facts: facts, + randomNumber: randomNumber, + size_mod: "" +%} +LIQUID; + + // Apply the same replacement logic as in Plugin::applyLiquidReplacements + $convertedLiquid = preg_replace( + '/{%\s*render\s+([^}]+?)\s+with\s+/i', + '{% render $1, ', + $originalLiquid + ); + + // Verify the conversion worked + $this->assertStringContainsString('{% render "session",', $convertedLiquid); + $this->assertStringNotContainsString('{% render "session" with', $convertedLiquid); + + // Verify the rest of the content is preserved + $this->assertStringContainsString('trmnl: trmnl,', $convertedLiquid); + $this->assertStringContainsString('facts: facts,', $convertedLiquid); + } + + public function test_template_tag_with_render_with_tag(): void + { + $originalLiquid = <<<'LIQUID' +{% template session %} +
    +
    +
    +
    +
    + {{ facts[randomNumber] }} +
    +
    +
    +
    +
    +{% endtemplate %} + +{% render "session" with + trmnl: trmnl, + facts: facts, + randomNumber: randomNumber, + size_mod: "" +%} +LIQUID; + + // Apply the same replacement logic as in applyLiquidReplacements + $convertedLiquid = preg_replace( + '/{%\s*render\s+([^}]+?)\s+with\s+/i', + '{% render $1, ', + $originalLiquid + ); + + $template = $this->environment->parseString($convertedLiquid); + + $context = $this->environment->newRenderContext( + data: [ + 'facts' => ['Fact 1', 'Fact 2', 'Fact 3'], + 'randomNumber' => 1, + 'trmnl' => ['plugin_settings' => ['instance_name' => 'Test']], + ] + ); + + $result = $template->render($context); + + // Should render the template content + $this->assertStringContainsString('Fact 2', $result); // facts[1] + $this->assertStringContainsString('class="layout"', $result); + $this->assertStringContainsString('class="value text--center"', $result); + } + + public function test_template_tag_with_multiple_templates(): void + { + $template = $this->environment->parseString(<<<'LIQUID' +{% template session %} +
    +
    +
    +
    +
    + {{ facts[randomNumber] }} +
    +
    +
    +
    +
    +{% endtemplate %} + +{% template title_bar %} +
    + + {{ trmnl.plugin_settings.instance_name }} + {{ instance }} +
    +{% endtemplate %} + +
    +{% render "session", + trmnl: trmnl, + facts: facts, + randomNumber: randomNumber, + size_mod: "" +%} + +{% render "title_bar", + trmnl: trmnl, + instance: "Please try to enjoy each fact equally." +%} +
    +LIQUID + ); + + $context = $this->environment->newRenderContext( + data: [ + 'size' => 'full', + 'facts' => ['Fact 1', 'Fact 2', 'Fact 3'], + 'randomNumber' => 1, + 'trmnl' => ['plugin_settings' => ['instance_name' => 'Test Plugin']], + ] + ); + + $result = $template->render($context); + + // Should render both templates + $this->assertStringContainsString('Fact 2', $result); + $this->assertStringContainsString('Test Plugin', $result); + $this->assertStringContainsString('Please try to enjoy each fact equally', $result); + $this->assertStringContainsString('class="view view--full"', $result); + } + + public function test_template_tag_invalid_name(): void + { + $this->expectException(LiquidException::class); + + $template = $this->environment->parseString(<<<'LIQUID' +{% template invalid-name %} +
    Content
    +{% endtemplate %} +LIQUID + ); + + $context = $this->environment->newRenderContext(); + + $template->render($context); + } + + public function test_template_tag_without_file_system(): void + { + $template = $this->environment->parseString(<<<'LIQUID' +{% template session %} +
    Content
    +{% endtemplate %} +LIQUID + ); + + $context = $this->environment->newRenderContext(); + + $result = $template->render($context); + + // Should not throw an error and should return empty string + $this->assertEquals('', $result); + } + + public function test_quotes_template_with_modulo_filter(): void + { + $template = $this->environment->parseString(<<<'LIQUID' +{% assign quotes_array = quotes[trmnl.plugin_settings.custom_fields_values.language] %} +{% assign random_index = 'now' | date: '%s' | modulo: quotes_array.size %} +{{ quotes_array[random_index] }} +LIQUID + ); + + $context = $this->environment->newRenderContext( + data: [ + 'quotes' => [ + 'english' => ['Demo Quote'], + 'german' => ['Demo Zitat'], + ], + 'trmnl' => [ + 'plugin_settings' => [ + 'custom_fields_values' => [ + 'language' => 'english', + ], + ], + ], + ] + ); + + $result = $template->render($context); + // Should render a quote from the english array + $this->assertStringContainsString('Demo Quote', $result); + $this->assertStringNotContainsString('Demo Zitat', $result); + } +} diff --git a/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php b/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php new file mode 100644 index 0000000..ee4d2fd --- /dev/null +++ b/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php @@ -0,0 +1,201 @@ + 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 new file mode 100644 index 0000000..f28f4cd --- /dev/null +++ b/tests/Unit/Models/DeviceLogTest.php @@ -0,0 +1,66 @@ +create(); + $log = DeviceLog::factory()->create(['device_id' => $device->id]); + + expect($log->device)->toBeInstanceOf(Device::class) + ->and($log->device->id)->toBe($device->id); +}); + +test('device log casts log_entry to array', function (): void { + Device::factory()->create(); + $log = DeviceLog::factory()->create([ + 'log_entry' => [ + 'message' => 'test message', + 'level' => 'info', + 'timestamp' => time(), + ], + ]); + + expect($log->log_entry)->toBeArray() + ->and($log->log_entry['message'])->toBe('test message') + ->and($log->log_entry['level'])->toBe('info'); +}); + +test('device log casts device_timestamp to datetime', function (): void { + Device::factory()->create(); + $timestamp = now(); + $log = DeviceLog::factory()->create([ + 'device_timestamp' => $timestamp, + ]); + + expect($log->device_timestamp)->toBeInstanceOf(Carbon\Carbon::class) + ->and($log->device_timestamp->timestamp)->toBe($timestamp->timestamp); +}); + +test('device log factory creates valid data', function (): void { + Device::factory()->create(); + $log = DeviceLog::factory()->create(); + + expect($log->device_id)->toBeInt() + ->and($log->device_timestamp)->toBeInstanceOf(Carbon\Carbon::class) + ->and($log->log_entry)->toBeArray() + ->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 { + $device = Device::factory()->create(); + $log = DeviceLog::create([ + 'device_id' => $device->id, + 'device_timestamp' => now(), + 'log_entry' => [ + 'message' => 'test message', + ], + ]); + + expect($log->exists)->toBeTrue() + ->and($log->device_id)->toBe($device->id) + ->and($log->log_entry['message'])->toBe('test message'); +}); diff --git a/tests/Unit/Models/DeviceModelTest.php b/tests/Unit/Models/DeviceModelTest.php new file mode 100644 index 0000000..8c2b6e9 --- /dev/null +++ b/tests/Unit/Models/DeviceModelTest.php @@ -0,0 +1,119 @@ +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 new file mode 100644 index 0000000..428a165 --- /dev/null +++ b/tests/Unit/Models/PlaylistItemTest.php @@ -0,0 +1,210 @@ +create(); + $playlistItem = PlaylistItem::factory()->create(['playlist_id' => $playlist->id]); + + expect($playlistItem->playlist) + ->toBeInstanceOf(Playlist::class) + ->id->toBe($playlist->id); +}); + +test('playlist item belongs to plugin', function (): void { + $plugin = Plugin::factory()->create(); + $playlistItem = PlaylistItem::factory()->create(['plugin_id' => $plugin->id]); + + expect($playlistItem->plugin) + ->toBeInstanceOf(Plugin::class) + ->id->toBe($plugin->id); +}); + +test('playlist item can check if it is a mashup', function (): void { + $plugin = Plugin::factory()->create(); + $regularItem = PlaylistItem::factory()->create([ + 'mashup' => null, + 'plugin_id' => $plugin->id, + ]); + + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => '1Lx1R', + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id], + ], + ]); + + expect($regularItem->isMashup())->toBeFalse() + ->and($mashupItem->isMashup())->toBeTrue(); +}); + +test('playlist item can get mashup name', function (): void { + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => '1Lx1R', + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id], + ], + ]); + + expect($mashupItem->getMashupName())->toBe('Test Mashup'); +}); + +test('playlist item can get mashup layout type', function (): void { + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => '1Lx1R', + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id], + ], + ]); + + expect($mashupItem->getMashupLayoutType())->toBe('1Lx1R'); +}); + +test('playlist item can get mashup plugin ids', function (): void { + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => '1Lx1R', + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id], + ], + ]); + + expect($mashupItem->getMashupPluginIds())->toBe([$plugin1->id, $plugin2->id]); +}); + +test('playlist item can get required plugin count for different layouts', function (): void { + $layouts = [ + '1Lx1R' => 2, + '1Tx1B' => 2, + '1Lx2R' => 3, + '2Lx1R' => 3, + '2Tx1B' => 3, + '1Tx2B' => 3, + '2x2' => 4, + ]; + + foreach ($layouts as $layout => $expectedCount) { + $plugins = Plugin::factory()->count($expectedCount)->create(); + $pluginIds = $plugins->pluck('id')->toArray(); + + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $pluginIds[0], + 'mashup' => [ + 'mashup_layout' => $layout, + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => $pluginIds, + ], + ]); + + expect($mashupItem->getRequiredPluginCount())->toBe($expectedCount); + } +}); + +test('playlist item can get layout type', function (): void { + $layoutTypes = [ + '1Lx1R' => 'vertical', + '1Lx2R' => 'vertical', + '2Lx1R' => 'vertical', + '1Tx1B' => 'horizontal', + '2Tx1B' => 'horizontal', + '1Tx2B' => 'horizontal', + '2x2' => 'grid', + ]; + + foreach ($layoutTypes as $layout => $expectedType) { + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => $layout, + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id], + ], + ]); + + expect($mashupItem->getLayoutType())->toBe($expectedType); + } +}); + +test('playlist item can get layout size for different positions', function (): void { + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $plugin3 = Plugin::factory()->create(); + + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => '2Lx1R', + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id, $plugin3->id], + ], + ]); + + expect($mashupItem->getLayoutSize(0))->toBe('quadrant') + ->and($mashupItem->getLayoutSize(1))->toBe('quadrant') + ->and($mashupItem->getLayoutSize(2))->toBe('half_vertical'); +}); + +test('playlist item can get available layouts', function (): void { + $layouts = PlaylistItem::getAvailableLayouts(); + + expect($layouts)->toBeArray() + ->toHaveKeys(['1Lx1R', '1Lx2R', '2Lx1R', '1Tx1B', '2Tx1B', '1Tx2B', '2x2']) + ->and($layouts['1Lx1R'])->toBe('1 Left - 1 Right (2 plugins)'); +}); + +test('playlist item can get required plugin count for layout', function (): void { + $layouts = [ + '1Lx1R' => 2, + '1Tx1B' => 2, + '1Lx2R' => 3, + '2Lx1R' => 3, + '2Tx1B' => 3, + '1Tx2B' => 3, + '2x2' => 4, + ]; + + foreach ($layouts as $layout => $expectedCount) { + expect(PlaylistItem::getRequiredPluginCountForLayout($layout))->toBe($expectedCount); + } +}); + +test('playlist item can create mashup', function (): void { + $playlist = Playlist::factory()->create(); + $plugins = Plugin::factory()->count(3)->create(); + $pluginIds = $plugins->pluck('id')->toArray(); + $layout = '2Lx1R'; + $name = 'Test Mashup'; + $order = 1; + + $mashup = PlaylistItem::createMashup($playlist, $layout, $pluginIds, $name, $order); + + expect($mashup) + ->toBeInstanceOf(PlaylistItem::class) + ->playlist_id->toBe($playlist->id) + ->plugin_id->toBe($pluginIds[0]) + ->mashup->toHaveKeys(['mashup_layout', 'mashup_name', 'plugin_ids']) + ->mashup->mashup_layout->toBe($layout) + ->mashup->mashup_name->toBe($name) + ->mashup->plugin_ids->toBe($pluginIds) + ->is_active->toBeTrue() + ->order->toBe($order); +}); diff --git a/tests/Unit/Models/PlaylistTest.php b/tests/Unit/Models/PlaylistTest.php new file mode 100644 index 0000000..62d3aaf --- /dev/null +++ b/tests/Unit/Models/PlaylistTest.php @@ -0,0 +1,46 @@ +create([ + 'name' => 'Test Playlist', + 'is_active' => true, + 'weekdays' => [1, 2, 3], + 'active_from' => '09:00', + 'active_until' => '17:00', + ]); + + expect($playlist) + ->name->toBe('Test Playlist') + ->is_active->toBeTrue() + ->weekdays->toBe([1, 2, 3]) + ->active_from->format('H:i')->toBe('09:00') + ->active_until->format('H:i')->toBe('17:00'); +}); + +test('playlist belongs to device', function (): void { + $device = Device::factory()->create(); + $playlist = Playlist::factory()->create(['device_id' => $device->id]); + + expect($playlist->device) + ->toBeInstanceOf(Device::class) + ->id->toBe($device->id); +}); + +test('playlist has many items', function (): void { + $playlist = Playlist::factory()->create(); + $items = PlaylistItem::factory()->count(3)->create(['playlist_id' => $playlist->id]); + + expect($playlist->items) + ->toHaveCount(3) + ->each->toBeInstanceOf(PlaylistItem::class); +}); + +test('getNextPlaylistItem returns null when playlist is inactive', function (): void { + $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 new file mode 100644 index 0000000..aa9a28e --- /dev/null +++ b/tests/Unit/Models/PluginTest.php @@ -0,0 +1,940 @@ +create([ + 'name' => 'Test Plugin', + 'data_payload' => ['key' => 'value'], + ]); + + expect($plugin) + ->name->toBe('Test Plugin') + ->data_payload->toBe(['key' => 'value']) + ->uuid->toBeString() + ->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 { + $plugin = Plugin::factory()->create(); + + expect($plugin->uuid) + ->toBeString() + ->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 { + $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 { + $data = ['key' => 'value']; + $plugin = Plugin::factory()->create(['data_payload' => $data]); + + expect($plugin->data_payload) + ->toBeArray() + ->toBe($data); +}); + +test('plugin can have polling body for POST requests', function (): void { + $plugin = Plugin::factory()->create([ + 'polling_verb' => 'post', + 'polling_body' => '{"query": "query { user { id name } }"}', + ]); + + expect($plugin->polling_body)->toBe('{"query": "query { user { id name } }"}'); +}); + +test('updateDataPayload sends POST request with body when polling_verb is post', function (): void { + Http::fake([ + 'https://example.com/api' => Http::response(['success' => true], 200), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/api', + 'polling_verb' => 'post', + 'polling_body' => '{"query": "query { user { id name } }"}', + ]); + + $plugin->updateDataPayload(); + + Http::assertSent(fn ($request): bool => $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 { + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/weather\nhttps://api3.example.com/news", + 'polling_verb' => 'get', + 'configuration' => [ + 'api_key' => 'test123', + ], + ]); + + // Mock HTTP responses + Http::fake([ + 'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200), + 'https://api2.example.com/weather' => Http::response(['temp' => 25], 200), + 'https://api3.example.com/news' => Http::response(['headline' => 'test'], 200), + ]); + + $plugin->updateDataPayload(); + + expect($plugin->data_payload)->toHaveKey('IDX_0'); + expect($plugin->data_payload)->toHaveKey('IDX_1'); + expect($plugin->data_payload)->toHaveKey('IDX_2'); + expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']); + expect($plugin->data_payload['IDX_1'])->toBe(['temp' => 25]); + 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 { + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.example.com/data', + 'polling_verb' => 'get', + 'configuration' => [ + 'api_key' => 'test123', + ], + ]); + + // Mock HTTP response + Http::fake([ + 'https://api.example.com/data' => Http::response(['data' => 'test'], 200), + ]); + + $plugin->updateDataPayload(); + + expect($plugin->data_payload)->toBe(['data' => 'test']); + expect($plugin->data_payload)->not->toHaveKey('IDX_0'); +}); + +test('updateDataPayload resolves Liquid variables in polling_header', function (): void { + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.example.com/data', + 'polling_verb' => 'get', + 'polling_header' => "Authorization: Bearer {{ api_key }}\nX-Custom-Header: {{ custom_value }}", + 'configuration' => [ + 'api_key' => 'test123', + 'custom_value' => 'custom_header_value', + ], + ]); + + // Mock HTTP response + Http::fake([ + 'https://api.example.com/data' => Http::response(['data' => 'test'], 200), + ]); + + $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'); +}); + +test('updateDataPayload resolves Liquid variables in polling_body', function (): void { + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.example.com/data', + 'polling_verb' => 'post', + 'polling_body' => '{"query": "query { user { id name } }", "api_key": "{{ api_key }}", "user_id": "{{ user_id }}"}', + 'configuration' => [ + 'api_key' => 'test123', + 'user_id' => '456', + ], + ]); + + // Mock HTTP response + Http::fake([ + 'https://api.example.com/data' => Http::response(['data' => 'test'], 200), + ]); + + $plugin->updateDataPayload(); + + Http::assertSent(function ($request): bool { + $expectedBody = '{"query": "query { user { id name } }", "api_key": "test123", "user_id": "456"}'; + + return $request->url() === 'https://api.example.com/data' && + $request->method() === 'POST' && + $request->body() === $expectedBody; + }); +}); + +test('webhook plugin is stale if webhook event occurred', function (): void { + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'webhook', + 'data_payload_updated_at' => now()->subMinutes(10), + 'data_stale_minutes' => 60, // Should be ignored for webhook + ]); + + expect($plugin->isDataStale())->toBeTrue(); + +}); + +test('webhook plugin data not stale if no webhook event occurred for 1 hour', function (): void { + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'webhook', + 'data_payload_updated_at' => now()->subMinutes(60), + 'data_stale_minutes' => 60, // Should be ignored for webhook + ]); + + expect($plugin->isDataStale())->toBeFalse(); + +}); + +test('plugin configuration is cast to array', function (): void { + $config = ['timezone' => 'UTC', 'refresh_interval' => 30]; + $plugin = Plugin::factory()->create(['configuration' => $config]); + + expect($plugin->configuration) + ->toBeArray() + ->toBe($config); +}); + +test('plugin can get configuration value by key', function (): void { + $config = ['timezone' => 'UTC', 'refresh_interval' => 30]; + $plugin = Plugin::factory()->create(['configuration' => $config]); + + expect($plugin->getConfiguration('timezone'))->toBe('UTC'); + expect($plugin->getConfiguration('refresh_interval'))->toBe(30); + expect($plugin->getConfiguration('nonexistent', 'default'))->toBe('default'); +}); + +test('plugin configuration template is cast to array', function (): void { + $template = [ + 'custom_fields' => [ + [ + 'name' => 'Timezone', + 'keyname' => 'timezone', + 'field_type' => 'time_zone', + 'description' => 'Select your timezone', + ], + ], + ]; + $plugin = Plugin::factory()->create(['configuration_template' => $template]); + + expect($plugin->configuration_template) + ->toBeArray() + ->toBe($template); +}); + +test('resolveLiquidVariables resolves variables from configuration', function (): void { + $plugin = Plugin::factory()->create([ + 'configuration' => [ + 'api_key' => '12345', + 'username' => 'testuser', + 'count' => 42, + ], + ]); + + // Test simple variable replacement + $template = 'API Key: {{ api_key }}'; + $result = $plugin->resolveLiquidVariables($template); + expect($result)->toBe('API Key: 12345'); + + // Test multiple variables + $template = 'User: {{ username }}, Count: {{ count }}'; + $result = $plugin->resolveLiquidVariables($template); + expect($result)->toBe('User: testuser, Count: 42'); + + // Test with missing variable (should keep original) + $template = 'Missing: {{ missing }}'; + $result = $plugin->resolveLiquidVariables($template); + expect($result)->toBe('Missing: '); + + // Test with Liquid control structures + $template = '{% if count > 40 %}High{% else %}Low{% endif %}'; + $result = $plugin->resolveLiquidVariables($template); + expect($result)->toBe('High'); +}); + +test('resolveLiquidVariables handles invalid Liquid syntax gracefully', function (): void { + $plugin = Plugin::factory()->create([ + 'configuration' => [ + 'api_key' => '12345', + ], + ]); + + // Test with unclosed Liquid tag (should throw exception) + $template = 'Unclosed tag: {{ config.api_key'; + + expect(fn () => $plugin->resolveLiquidVariables($template)) + ->toThrow(Keepsuit\Liquid\Exceptions\SyntaxException::class); +}); + +test('plugin can extract default values from custom fields configuration template', function (): void { + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'reading_days', + 'field_type' => 'string', + 'name' => 'Reading Days', + 'description' => 'Select days of the week to read', + 'default' => 'Monday,Friday,Saturday,Sunday', + ], + [ + 'keyname' => 'refresh_interval', + 'field_type' => 'number', + 'name' => 'Refresh Interval', + 'description' => 'How often to refresh data', + 'default' => 30, + ], + [ + 'keyname' => 'timezone', + 'field_type' => 'time_zone', + 'name' => 'Timezone', + 'description' => 'Select your timezone', + // No default value + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'reading_days' => 'Monday,Friday,Saturday,Sunday', + 'refresh_interval' => 30, + ], + ]); + + expect($plugin->configuration) + ->toBeArray() + ->toHaveKey('reading_days') + ->toHaveKey('refresh_interval') + ->not->toHaveKey('timezone'); + + expect($plugin->getConfiguration('reading_days'))->toBe('Monday,Friday,Saturday,Sunday'); + expect($plugin->getConfiguration('refresh_interval'))->toBe(30); + expect($plugin->getConfiguration('timezone'))->toBeNull(); +}); + +test('resolveLiquidVariables resolves configuration variables correctly', function (): void { + $plugin = Plugin::factory()->create([ + 'configuration' => [ + 'Latitude' => '48.2083', + 'Longitude' => '16.3731', + 'api_key' => 'test123', + ], + ]); + + $template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}'; + $expected = 'https://suntracker.me/?lat=48.2083&lon=16.3731'; + + expect($plugin->resolveLiquidVariables($template))->toBe($expected); +}); + +test('resolveLiquidVariables handles missing variables gracefully', function (): void { + $plugin = Plugin::factory()->create([ + 'configuration' => [ + 'Latitude' => '48.2083', + ], + ]); + + $template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}&key={{ api_key }}'; + $expected = 'https://suntracker.me/?lat=48.2083&lon=&key='; + + expect($plugin->resolveLiquidVariables($template))->toBe($expected); +}); + +test('resolveLiquidVariables handles empty configuration', function (): void { + $plugin = Plugin::factory()->create([ + 'configuration' => [], + ]); + + $template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}'; + $expected = 'https://suntracker.me/?lat=&lon='; + + 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 ', '