From 903d41e9d401a1bd8167b54ebb378821c4ac6b6b Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 13:17:32 +0500 Subject: [PATCH 01/13] Foundations: PHP 8.4, Laravel 12/13, Pint, CI matrix, Codecov; write files with OpenSpout maatwebsite/excel can't be installed on PHP 8.5 (PhpSpreadsheet 1.30 caps PHP below 8.5), so it's replaced here rather than later: exports are written with OpenSpout (CSV, XLSX, ODS) through SpreadsheetWriter, and ModelExport gets download(), store() and writeTo() from the WritesFiles trait. The v1 ModelExport API and output are unchanged for now. Closes #1 Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 11 +- .github/coverage.svg | 20 -- .github/workflows/run-tests.yml | 81 ----- .github/workflows/tests.yml | 82 +++++ .gitignore | 3 +- .php_cs.dist.php | 35 -- CHANGELOG.md | 7 +- README.md | 34 +- composer.json | 31 +- config/exports.php | 112 +++++++ lang/dv.json | 1 + lang/en.json | 1 + pint.json | 20 ++ src/Concerns/WritesFiles.php | 110 +++++++ src/Contracts/Exportable.php | 33 ++ src/Enums/Formats.php | 76 +++++ src/ExportsServiceProvider.php | 25 +- src/IterableExport.php | 61 ++++ src/ModelExport.php | 146 ++++++--- src/Support/TemporaryFile.php | 30 ++ src/Writers/SpreadsheetWriter.php | 237 ++++++++++++++ tests/Feature/WritingFilesTest.php | 309 ++++++++++++++++++ tests/TestCase.php | 66 +++- tests/TestSupport/Enums/UserStatuses.php | 6 +- tests/TestSupport/Exports/UsersExport.php | 1 - tests/TestSupport/Models/Organization.php | 2 +- tests/TestSupport/Models/Role.php | 2 +- tests/TestSupport/Models/User.php | 6 +- .../Providers/TestServiceProvider.php | 5 +- .../2024_01_05_223412_create_roles_table.php | 5 +- .../2024_02_05_223412_create_users_table.php | 5 +- ...3_05_223412_create_organizations_table.php | 5 +- ..._223412_create_organization_user_table.php | 5 +- tests/Unit/ModelExportTest.php | 23 +- 34 files changed, 1337 insertions(+), 259 deletions(-) delete mode 100644 .github/coverage.svg delete mode 100644 .github/workflows/run-tests.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .php_cs.dist.php create mode 100644 config/exports.php create mode 100644 lang/dv.json create mode 100644 lang/en.json create mode 100644 pint.json create mode 100644 src/Concerns/WritesFiles.php create mode 100644 src/Contracts/Exportable.php create mode 100644 src/Enums/Formats.php create mode 100644 src/IterableExport.php create mode 100644 src/Support/TemporaryFile.php create mode 100644 src/Writers/SpreadsheetWriter.php create mode 100644 tests/Feature/WritingFilesTest.php diff --git a/.gitattributes b/.gitattributes index bb6265e..f23a7c9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,14 @@ # Path-based git attributes # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html -# Ignore all test and documentation with "export-ignore". +# Keep development files out of the Composer package. docs, llms.txt and +# resources/boost ship with it, for AI assistants and Laravel Boost. /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore +/.github export-ignore +/.editorconfig export-ignore /phpunit.xml.dist export-ignore -/.scrutinizer.yml export-ignore +/pint.json export-ignore /tests export-ignore -/.editorconfig export-ignore +/AGENTS.md export-ignore +/CLAUDE.md export-ignore diff --git a/.github/coverage.svg b/.github/coverage.svg deleted file mode 100644 index 12a56b7..0000000 --- a/.github/coverage.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - coverage - - 86 % - - \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 1077e5e..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: tests - -on: - pull_request: - branches: - - main - push: - branches: - - main - -jobs: - tests: - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: true - matrix: - os: [ ubuntu-latest ] - php: [ 8.4, 8.3, 8.2, 8.1 ] - laravel: [ 13.*, 12.*, 11.*, 10.*, 9.* ] - stability: [ prefer-lowest, prefer-stable ] - include: - - laravel: 13.* - testbench: 11.* - carbon: ^3.8.4 - - laravel: 12.* - testbench: 10.* - carbon: ^3.8.4 - - laravel: 11.* - testbench: ^9.16 - carbon: ^2.63 - - laravel: 10.* - testbench: 8.* - carbon: ^2.63 - - laravel: 9.* - testbench: 7.* - carbon: ^2.63 - exclude: - - laravel: 13.* - php: 8.2 - - laravel: 13.* - php: 8.1 - - laravel: 12.* - php: 8.1 - - laravel: 11.* - php: 8.1 - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install SQLite 3 - run: | - sudo apt-get update - sudo apt-get install sqlite3 -y - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv - coverage: ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && 'xdebug' || 'none' }} - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: Execute tests - run: | - vendor/bin/phpunit ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && '--coverage-clover=clover.xml' || '' }} - - - name: Make code coverage badge - if: startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' - uses: timkrase/phpunit-coverage-badge@v1.2.1 - with: - coverage_badge_path: .github/coverage.svg - push_badge: true - repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..074c4e8 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,82 @@ +name: tests + +on: + pull_request: + push: + branches: + - main + - next-gen + +jobs: + pint: + runs-on: ubuntu-latest + + name: Pint + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Check code style + run: vendor/bin/pint --test + + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [ 8.4, 8.5 ] + laravel: [ 12.*, 13.* ] + stability: [ prefer-lowest, prefer-stable ] + include: + - laravel: 12.* + testbench: 10.* + - laravel: 13.* + testbench: 11.* + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} + + env: + COVERAGE: ${{ matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv + coverage: ${{ env.COVERAGE == 'true' && 'pcov' || 'none' }} + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + + - name: Execute tests + if: env.COVERAGE != 'true' + run: vendor/bin/phpunit + + - name: Execute tests with coverage + if: env.COVERAGE == 'true' + run: vendor/bin/phpunit --coverage-clover build/coverage.xml + + - name: Upload coverage to Codecov + if: env.COVERAGE == 'true' + uses: codecov/codecov-action@v5 + with: + files: build/coverage.xml + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 8683765..8b64e81 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,5 @@ composer.lock vendor coverage .phpunit.result.cache -.php-cs-fixer.cache +.phpunit.cache .idea -clover.xml diff --git a/.php_cs.dist.php b/.php_cs.dist.php deleted file mode 100644 index 8648fdc..0000000 --- a/.php_cs.dist.php +++ /dev/null @@ -1,35 +0,0 @@ -in([ - __DIR__ . '/src', - __DIR__ . '/tests', - ]) - ->name('*.php') - ->notName('*.blade.php') - ->ignoreDotFiles(true) - ->ignoreVCS(true); - -return (new PhpCsFixer\Config()) - ->setRules([ - '@PSR12' => true, - 'array_syntax' => ['syntax' => 'short'], - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'no_unused_imports' => true, - 'not_operator_with_successor_space' => true, - 'trailing_comma_in_multiline' => true, - 'phpdoc_scalar' => true, - 'unary_operator_spaces' => true, - 'binary_operator_spaces' => true, - 'blank_line_before_statement' => [ - 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], - ], - 'phpdoc_single_line_var_spacing' => true, - 'phpdoc_var_without_name' => true, - 'method_argument_space' => [ - 'on_multiline' => 'ensure_fully_multiline', - 'keep_multiple_spaces_after_comma' => true, - ], - 'single_trait_insert_per_statement' => true, - ]) - ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md index 8869e77..ccd7152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog -All notable changes will be documented in this file +All notable changes will be documented in this file. -## 1.0.0 - 202X-XX-XX +## 2.0.0 - Unreleased -- initial release +- Requires PHP 8.4 and Laravel 12 or 13. +- Replaced maatwebsite/excel with OpenSpout: CSV, XLSX and ODS are streamed with flat memory. See `docs/upgrading.md`. diff --git a/README.md b/README.md index f5510d1..20fd3fc 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,29 @@ # Exports -[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/exports.svg?style=flat-square)](https://packagist.org/packages/javaabu/exports) -[![Test Status](../../actions/workflows/run-tests.yml/badge.svg)](../../actions/workflows/run-tests.yml) -![Code Coverage Badge](./.github/coverage.svg) -[![Total Downloads](https://img.shields.io/packagist/dt/javaabu/exports.svg?style=flat-square)](https://packagist.org/packages/javaabu/exports) +[![Tests](https://github.com/n4ff4h/exports/actions/workflows/tests.yml/badge.svg?branch=next-gen)](https://github.com/n4ff4h/exports/actions/workflows/tests.yml?query=branch%3Anext-gen) +[![codecov](https://codecov.io/gh/n4ff4h/exports/branch/next-gen/graph/badge.svg)](https://codecov.io/gh/n4ff4h/exports/tree/next-gen) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/exports.svg)](https://packagist.org/packages/javaabu/exports) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) -## Introduction -Handles Laravel exports using maatwebsite/excel +Streaming model exports for Laravel. Write CSV, XLSX and ODS files with flat memory, however many rows there are. + +```php +use Javaabu\Exports\ModelExport; + +class UsersExport extends ModelExport +{ + public function modelClass(): string + { + return User::class; + } +} + +return UsersExport::make()->download('users.xlsx'); +``` ## Documentation -You'll find the documentation on [https://docs.javaabu.com/exports](https://docs.javaabu.com/exports). +You'll find the documentation on [https://docs.javaabu.com/exports](https://docs.javaabu.com/exports), and in the [`docs`](docs) folder. Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving this package? Feel free to create an [issue](../../issues) on GitHub, we'll try to address it as soon as possible. @@ -18,10 +31,9 @@ If you've found a bug regarding security please mail [info@javaabu.com](mailto:i ## Testing -You can run the tests with - -``` bash -./vendor/bin/phpunit +```bash +composer test +composer lint ``` ## Changelog diff --git a/composer.json b/composer.json index 06364f6..90bee97 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,15 @@ { "name": "javaabu/exports", - "description": "Handles Laravel exports using maatwebsite/excel", + "description": "Streaming model exports for Laravel: smart defaults, CSV, XLSX and ODS, background jobs and an exports UI with live progress", "homepage": "https://github.com/Javaabu/exports", "license": "MIT", "keywords": [ "javaabu", "laravel", - "exports" + "exports", + "csv", + "xlsx", + "openspout" ], "authors": [ { @@ -16,14 +19,15 @@ } ], "require": { - "php": "^8.1", - "illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", - "maatwebsite/excel": "^3.1" + "php": "^8.4", + "illuminate/support": "^12.0 || ^13.0", + "openspout/openspout": "^5.0" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^7.0 || ^8.0 || ^9.16 || ^10.0 || ^11.0", - "phpunit/phpunit": "^9.5 || ^10.5 || ^11.5.3 || ^12.5.8 || ^13.0.3" + "javaabu/boost-javaabu-guidelines": "^1.0", + "laravel/pint": "^1.24", + "orchestra/testbench": "^10.0 || ^11.0", + "phpunit/phpunit": "^11.5.3 || ^12.5.8" }, "autoload": { "psr-4": { @@ -35,9 +39,15 @@ "Javaabu\\Exports\\Tests\\": "tests/" } }, + "scripts": { + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-clover build/coverage.xml", + "lint": "vendor/bin/pint --test", + "format": "vendor/bin/pint" + }, "extra": { "branch-alias": { - "dev-main": "1.0-dev" + "dev-next-gen": "2.x-dev" }, "laravel": { "providers": [ @@ -45,6 +55,9 @@ ] } }, + "config": { + "sort-packages": true + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/config/exports.php b/config/exports.php new file mode 100644 index 0000000..57d33ca --- /dev/null +++ b/config/exports.php @@ -0,0 +1,112 @@ + 'xlsx', + + /* + |-------------------------------------------------------------------------- + | Enabled formats + |-------------------------------------------------------------------------- + | + | The formats users may choose from in the exports UI and API. + | + */ + + 'formats' => ['xlsx', 'csv', 'ods'], + + /* + |-------------------------------------------------------------------------- + | Storage + |-------------------------------------------------------------------------- + | + | Where stored and queued exports are written. `disk` null means the app's + | default filesystem disk. Files are written locally to the temporary + | directory first (null means the system temp directory), then streamed + | to the disk. + | + */ + + 'disk' => env('EXPORTS_DISK'), + + 'directory' => 'exports', + + 'temporary_directory' => null, + + /* + |-------------------------------------------------------------------------- + | Reading + |-------------------------------------------------------------------------- + | + | How many models are loaded from the database at a time. Exports read with + | lazyById() (or lazy() when the query has its own order), so memory depends + | on this number, not on how many rows there are. + | + */ + + 'chunk_size' => 1000, + + /* + |-------------------------------------------------------------------------- + | Formatting + |-------------------------------------------------------------------------- + */ + + 'datetime_format' => 'Y-m-d H:i:s', + + 'date_format' => 'Y-m-d', + + /* + |-------------------------------------------------------------------------- + | Right-to-left sheets + |-------------------------------------------------------------------------- + | + | XLSX sheets are right-to-left when this is true, or when it's 'auto' and + | the app locale is one of the rtl_locales. + | + */ + + 'rtl' => 'auto', + + 'rtl_locales' => ['dv', 'ar', 'fa', 'he', 'ur'], + + /* + |-------------------------------------------------------------------------- + | CSV + |-------------------------------------------------------------------------- + | + | The BOM lets Excel open UTF-8 (including Thaana) correctly. Values that a + | spreadsheet would run as a formula (starting with =, +, -, @) are + | prefixed with an apostrophe unless escape_formulas is false. + | + */ + + 'csv' => [ + 'delimiter' => ',', + 'enclosure' => '"', + 'bom' => true, + 'escape_formulas' => true, + ], + + /* + |-------------------------------------------------------------------------- + | XLSX + |-------------------------------------------------------------------------- + */ + + 'xlsx' => [ + 'auto_size' => true, + 'max_column_width' => 60, + ], + +]; diff --git a/lang/dv.json b/lang/dv.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/lang/dv.json @@ -0,0 +1 @@ +{} diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/lang/en.json @@ -0,0 +1 @@ +{} diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..44df8bf --- /dev/null +++ b/pint.json @@ -0,0 +1,20 @@ +{ + "preset": "laravel", + "rules": { + "concat_space": { + "spacing": "one" + }, + "global_namespace_import": { + "import_classes": true, + "import_constants": false, + "import_functions": false + }, + "no_unused_imports": true, + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "php_unit_method_casing": { + "case": "snake_case" + } + } +} diff --git a/src/Concerns/WritesFiles.php b/src/Concerns/WritesFiles.php new file mode 100644 index 0000000..cdc44bd --- /dev/null +++ b/src/Concerns/WritesFiles.php @@ -0,0 +1,110 @@ +write( + $path, + Formats::resolve($format, $path), + $this->headings(), + $this->rows(), + ); + } + + /** + * A download response for the export, written before the response is sent + */ + public function download(?string $filename = null, Formats|string|null $format = null): BinaryFileResponse + { + $format = Formats::resolve($format, $filename); + $path = TemporaryFile::path($format); + + $this->writeTo($path, $format); + + return response() + ->download($path, $this->downloadName($filename, $format), ['Content-Type' => $format->mimeType()]) + ->deleteFileAfterSend(); + } + + /** + * Write the export to a storage disk + * + * @return string the path on the disk + */ + public function store(?string $path = null, ?string $disk = null, Formats|string|null $format = null): string + { + $format = Formats::resolve($format, $path); + $path ??= trim((string) config('exports.directory', 'exports'), '/') . '/' . $this->downloadName(null, $format); + $local_path = TemporaryFile::path($format); + + try { + $this->writeTo($local_path, $format); + + $this->putOnDisk($local_path, $path, $disk); + } finally { + TemporaryFile::delete($local_path); + } + + return $path; + } + + /** + * The file name for a download: the given name, with the format's extension added if missing, + * or the export's file name and the current time + */ + public function downloadName(?string $filename, Formats $format): string + { + if (! $filename) { + $filename = $this->filename() . '-' . now()->format('Y-m-d-His'); + } + + if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) === $format->extension()) { + return $filename; + } + + return "{$filename}.{$format->extension()}"; + } + + protected function putOnDisk(string $local_path, string $path, ?string $disk): void + { + $stream = fopen($local_path, 'r'); + + try { + $stored = Storage::disk($disk ?? config('exports.disk'))->writeStream($path, $stream); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + if ($stored === false) { + throw new RuntimeException("Could not write the export to {$path}."); + } + } +} diff --git a/src/Contracts/Exportable.php b/src/Contracts/Exportable.php new file mode 100644 index 0000000..86b836b --- /dev/null +++ b/src/Contracts/Exportable.php @@ -0,0 +1,33 @@ + + */ + public function headings(): array; + + /** + * The data rows. Yield them lazily so memory stays flat however many rows there are. + * + * @return iterable> + */ + public function rows(): iterable; + + /** + * The number of rows, for progress, or null when unknown + */ + public function totalRows(): ?int; + + /** + * The file name without an extension, e.g. `users` + */ + public function filename(): string; +} diff --git a/src/Enums/Formats.php b/src/Enums/Formats.php new file mode 100644 index 0000000..46c6aa4 --- /dev/null +++ b/src/Enums/Formats.php @@ -0,0 +1,76 @@ +value; + } + + public function mimeType(): string + { + return match ($this) { + self::CSV => 'text/csv', + self::XLSX => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + self::ODS => 'application/vnd.oasis.opendocument.spreadsheet', + }; + } + + public function label(): string + { + return match ($this) { + self::CSV => __('CSV'), + self::XLSX => __('Excel (XLSX)'), + self::ODS => __('OpenDocument (ODS)'), + }; + } + + /** + * The format for a value, a file name with an extension, or the configured default + */ + public static function resolve(self|string|null $format = null, ?string $filename = null): self + { + if ($format instanceof self) { + return $format; + } + + if ($format) { + return self::tryFrom(strtolower($format)) + ?? throw new ValueError(__('Unsupported export format: :format', ['format' => $format])); + } + + $extension = $filename ? strtolower(pathinfo($filename, PATHINFO_EXTENSION)) : ''; + + if ($extension && ($from_extension = self::tryFrom($extension))) { + return $from_extension; + } + + return self::tryFrom((string) config('exports.format', self::XLSX->value)) ?? self::XLSX; + } + + /** + * The formats enabled in `config('exports.formats')` + * + * @return array + */ + public static function enabled(): array + { + $enabled = config('exports.formats', [self::XLSX->value, self::CSV->value, self::ODS->value]); + + return array_values(array_filter( + self::cases(), + fn (self $format) => in_array($format->value, $enabled, true), + )); + } +} diff --git a/src/ExportsServiceProvider.php b/src/ExportsServiceProvider.php index 5b5e402..6a300a5 100644 --- a/src/ExportsServiceProvider.php +++ b/src/ExportsServiceProvider.php @@ -6,19 +6,24 @@ class ExportsServiceProvider extends ServiceProvider { - /** - * Bootstrap the application services. - */ - public function boot() + public function register(): void { - // declare publishes + $this->mergeConfigFrom(__DIR__ . '/../config/exports.php', 'exports'); } - /** - * Register the application services. - */ - public function register() + public function boot(): void { - // merge package config with user defined config + $this->loadJsonTranslationsFrom(__DIR__ . '/../lang'); + + if ($this->app->runningInConsole()) { + $this->registerPublishing(); + } + } + + protected function registerPublishing(): void + { + $this->publishes([ + __DIR__ . '/../config/exports.php' => config_path('exports.php'), + ], 'exports-config'); } } diff --git a/src/IterableExport.php b/src/IterableExport.php new file mode 100644 index 0000000..1393432 --- /dev/null +++ b/src/IterableExport.php @@ -0,0 +1,61 @@ +download(); + * ``` + */ +class IterableExport implements Exportable +{ + use WritesFiles; + + /** + * @param array $headings + * @param iterable>|Closure(): iterable> $rows + */ + public function __construct( + protected array $headings, + protected iterable|Closure $rows, + protected string $filename = 'export', + protected ?int $total_rows = null, + ) {} + + public function headings(): array + { + return $this->headings; + } + + public function rows(): iterable + { + $rows = $this->rows instanceof Closure + ? ($this->rows)() + : $this->rows; + + foreach ($rows as $row) { + yield array_values((array) $row); + } + } + + public function totalRows(): ?int + { + if ($this->total_rows !== null) { + return $this->total_rows; + } + + return is_countable($this->rows) ? count($this->rows) : null; + } + + public function filename(): string + { + return $this->filename; + } +} diff --git a/src/ModelExport.php b/src/ModelExport.php index bab7e86..b3a23e9 100644 --- a/src/ModelExport.php +++ b/src/ModelExport.php @@ -1,67 +1,58 @@ */ - public function __construct($query = null) - { - $this->query = $query; - } + abstract public function modelClass(): string; /** - * Get the model class + * @return array */ - public abstract function modelClass(): string; - public function allowedAttributes(): array { - $model_class = $this->modelClass(); - - /** @var Model $empty_model */ - $empty_model = (new $model_class()); + $model = $this->newModel(); - $attributes = array_values(array_diff(\Schema::getColumnListing($empty_model->getTable()), $empty_model->getHidden())); + $attributes = array_values(array_diff( + Schema::connection($model->getConnectionName())->getColumnListing($model->getTable()), + $model->getHidden(), + )); return array_merge($attributes, $this->relationsToInclude()); } - /** - * @return Builder - */ - public function query() + public function query(): Builder|Relation { if ($this->query) { return $this->query; } - $model_class = $this->modelClass(); - - $query = $model_class::query(); + $query = $this->modelClass()::query(); if ($relations = $this->relationsToInclude()) { $query->with($relations); @@ -70,15 +61,64 @@ public function query() return $query; } + /** + * @return array + */ public function relationsToInclude(): array { return []; } /** - * @param Model $model + * @return iterable> + */ + public function rows(): iterable + { + foreach ($this->models() as $model) { + yield $this->map($model); + } + } + + /** + * The models, loaded a chunk at a time + * + * @return LazyCollection */ - public function map($model): array + public function models(): LazyCollection + { + $query = $this->query(); + $chunk_size = $this->chunkSize(); + + $base_query = $query->toBase(); + + if ($base_query->orders || $base_query->unionOrders) { + return $query->lazy($chunk_size); + } + + $model = $query->getModel(); + + return $query->lazyById($chunk_size, $model->getQualifiedKeyName(), $model->getKeyName()); + } + + public function totalRows(): ?int + { + return $this->query()->toBase()->getCountForPagination(); + } + + public function chunkSize(): int + { + return max((int) config('exports.chunk_size', 1000), 1); + } + + public function filename(): string + { + return Str::of(class_basename($this->modelClass()))->plural()->kebab()->toString(); + } + + /** + * @return array + */ + public function map(Model $model): array { $attributes = $model->only($this->allowedAttributes()); @@ -89,12 +129,12 @@ public function map($model): array return array_values($attributes); } - public function isAdminModel($model, string $attribute, mixed $value): bool + public function isAdminModel(Model $model, string $attribute, mixed $value): bool { return $value instanceof Model && method_exists($value, 'getAdminLinkNameAttribute'); } - public function isAdminModelCollection($model, string $attribute, mixed $value): bool + public function isAdminModelCollection(Model $model, string $attribute, mixed $value): bool { if (! $value instanceof Collection) { return false; @@ -109,7 +149,7 @@ public function isAdminModelCollection($model, string $attribute, mixed $value): return true; } - public function getEnumLabel($model, string $attribute, mixed $value): ?string + public function getEnumLabel(Model $model, string $attribute, mixed $value): ?string { if ($value instanceof BackedEnum) { return method_exists($value, 'getLabel') ? $value->getLabel() : $value->name; @@ -118,24 +158,33 @@ public function getEnumLabel($model, string $attribute, mixed $value): ?string return ''; } - public function formatValue($model, string $attribute, mixed $value): mixed + public function formatValue(Model $model, string $attribute, mixed $value): mixed { if ($value instanceof BackedEnum) { return $this->getEnumLabel($model, $attribute, $value); - } elseif ($this->isAdminModelCollection($model, $attribute, $value)) { + } + + if ($this->isAdminModelCollection($model, $attribute, $value)) { return $value->implode('admin_link_name', ','); - } elseif ($this->isAdminModel($model, $attribute, $value)) { + } + + if ($this->isAdminModel($model, $attribute, $value)) { return $value->admin_link_name; - } elseif (is_bool($value)) { + } + + if (is_bool($value)) { return $value ? 'True' : 'False'; } return $value; } + /** + * @return array + */ public function headings(): array { - return array_map(function ($slug) { + return array_map(function (string $slug) { return Str::of($slug) ->camel() ->snake() @@ -144,4 +193,11 @@ public function headings(): array ->toString(); }, $this->allowedAttributes()); } + + protected function newModel(): Model + { + $model_class = $this->modelClass(); + + return new $model_class; + } } diff --git a/src/Support/TemporaryFile.php b/src/Support/TemporaryFile.php new file mode 100644 index 0000000..75da2c3 --- /dev/null +++ b/src/Support/TemporaryFile.php @@ -0,0 +1,30 @@ +extension(); + } + + public static function delete(string $path): void + { + if (is_file($path)) { + unlink($path); + } + } +} diff --git a/src/Writers/SpreadsheetWriter.php b/src/Writers/SpreadsheetWriter.php new file mode 100644 index 0000000..ace5afd --- /dev/null +++ b/src/Writers/SpreadsheetWriter.php @@ -0,0 +1,237 @@ + column index => widest value, for auto-sizing */ + protected array $widths = []; + + /** + * @param array $headings + * @param iterable> $rows + * @return int the number of data rows written + */ + public function write(string $path, Formats $format, array $headings, iterable $rows): int + { + $this->widths = []; + + $writer = $this->makeWriter($format); + $writer->openToFile($path); + + $count = 0; + + try { + if ($format === Formats::XLSX && $this->isRtl() && $writer instanceof XlsxWriter) { + $writer->getCurrentSheet()->setSheetView($this->sheetView($headings !== [])); + } + + if ($headings !== []) { + $writer->addRow(new Row($this->cells($headings, $format, $this->headingStyle()))); + } + + foreach ($rows as $row) { + $writer->addRow(new Row($this->cells(array_values($row), $format))); + $count++; + } + + $this->applyColumnWidths($writer, $format); + } finally { + $writer->close(); + } + + return $count; + } + + protected function makeWriter(Formats $format): WriterInterface + { + $temporary_directory = $this->temporaryDirectory(); + + return match ($format) { + Formats::CSV => new CsvWriter(new CsvOptions( + FIELD_DELIMITER: (string) config('exports.csv.delimiter', ','), + FIELD_ENCLOSURE: (string) config('exports.csv.enclosure', '"'), + SHOULD_ADD_BOM: (bool) config('exports.csv.bom', true), + )), + Formats::XLSX => new XlsxWriter(new XlsxOptions(tempFolder: $temporary_directory)), + Formats::ODS => new OdsWriter(new OdsOptions(tempFolder: $temporary_directory)), + }; + } + + /** + * @param array $values + * @return array + */ + protected function cells(array $values, Formats $format, ?Style $style = null): array + { + $cells = []; + + foreach ($values as $index => $value) { + $cell = $this->cell($value, $format, $style); + + $this->trackWidth($index, $cell); + + $cells[] = $cell; + } + + return $cells; + } + + protected function cell(mixed $value, Formats $format, ?Style $style = null): Cell + { + if ($value === null || $value === '') { + return new EmptyCell(null, $style); + } + + if (is_bool($value)) { + return new BooleanCell($value, $style); + } + + if (is_int($value) || is_float($value)) { + return new NumericCell($value, $style); + } + + return new StringCell($this->escape($this->stringify($value), $format), $style); + } + + protected function stringify(mixed $value): string + { + return match (true) { + is_string($value) => $value, + $value instanceof DateTimeInterface => $value->format((string) config('exports.datetime_format', 'Y-m-d H:i:s')), + $value instanceof BackedEnum => (string) $value->value, + $value instanceof UnitEnum => $value->name, + $value instanceof Stringable => (string) $value, + $value instanceof JsonSerializable, is_array($value) => (string) json_encode($value, JSON_UNESCAPED_UNICODE), + is_object($value) => $value::class, + default => (string) $value, + }; + } + + /** + * Stop a CSV value from being run as a formula when the file is opened in a spreadsheet + */ + protected function escape(string $value, Formats $format): string + { + if ($format !== Formats::CSV) { + return $value; + } + + if (! config('exports.csv.escape_formulas', true)) { + return $value; + } + + if (! in_array($value[0], self::FORMULA_TRIGGERS, true)) { + return $value; + } + + if (is_numeric($value)) { + return $value; + } + + return "'{$value}"; + } + + protected function headingStyle(): Style + { + return (new Style)->withFontBold(true); + } + + protected function sheetView(bool $has_headings): SheetView + { + $view = (new SheetView)->withRightToLeft(true); + + return $has_headings ? $view->withFreezeRow(2) : $view; + } + + protected function trackWidth(int $index, Cell $cell): void + { + $value = $cell->getValue(); + + $length = is_string($value) ? mb_strlen($value) : strlen((string) $value); + + if ($length > ($this->widths[$index] ?? 0)) { + $this->widths[$index] = $length; + } + } + + /** + * Size XLSX columns to fit their widest value, up to `exports.xlsx.max_column_width` + */ + protected function applyColumnWidths(WriterInterface $writer, Formats $format): void + { + if ($format !== Formats::XLSX) { + return; + } + + if (! config('exports.xlsx.auto_size', true)) { + return; + } + + if (! $writer instanceof AbstractWriterMultiSheets) { + return; + } + + $max = (float) config('exports.xlsx.max_column_width', 60); + $sheet = $writer->getCurrentSheet(); + + foreach ($this->widths as $index => $length) { + $sheet->setColumnWidth(min(max($length + 2, 8), $max), $index + 1); + } + } + + protected function isRtl(): bool + { + $rtl = config('exports.rtl', 'auto'); + + if ($rtl !== 'auto') { + return (bool) $rtl; + } + + return in_array(app()->getLocale(), config('exports.rtl_locales', ['dv', 'ar', 'fa', 'he', 'ur']), true); + } + + protected function temporaryDirectory(): string + { + $directory = config('exports.temporary_directory') ?: sys_get_temp_dir(); + + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + return $directory; + } +} diff --git a/tests/Feature/WritingFilesTest.php b/tests/Feature/WritingFilesTest.php new file mode 100644 index 0000000..624262f --- /dev/null +++ b/tests/Feature/WritingFilesTest.php @@ -0,0 +1,309 @@ + + */ + public static function formats(): array + { + return [ + 'csv' => [Formats::CSV], + 'xlsx' => [Formats::XLSX], + 'ods' => [Formats::ODS], + ]; + } + + #[Test] + #[DataProvider('formats')] + public function it_writes_headings_and_rows_in_every_format(Formats $format): void + { + $path = TemporaryFile::path($format); + + $count = $this->islandsExport()->writeTo($path); + + $this->assertEquals(2, $count); + $this->assertEquals([ + ['Name', 'Atoll', 'Population'], + ['މާލެ', 'ކ', 252768], + ['ހިތަދޫ', 'ސ', 13000], + ], $this->normalise($this->readSpreadsheet($path))); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_prefixes_csv_values_that_would_run_as_formulas(): void + { + $path = TemporaryFile::path(Formats::CSV); + + IterableExport::make(['Value'], [['=1+2'], ['@SUM(A1)'], ['-5'], ['+960 7777777'], ['ok']])->writeTo($path); + + $this->assertEquals([ + ['Value'], + ["'=1+2"], + ["'@SUM(A1)"], + ['-5'], + ["'+960 7777777"], + ['ok'], + ], $this->readSpreadsheet($path)); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_can_be_told_not_to_escape_csv_formulas(): void + { + config()->set('exports.csv.escape_formulas', false); + + $path = TemporaryFile::path(Formats::CSV); + + IterableExport::make(['Value'], [['=1+2']])->writeTo($path); + + $this->assertEquals([['Value'], ['=1+2']], $this->readSpreadsheet($path)); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_never_writes_xlsx_strings_as_formulas(): void + { + $path = TemporaryFile::path(Formats::XLSX); + + IterableExport::make(['Value'], [['=1+2']])->writeTo($path); + + $sheet = $this->readFromArchive($path, 'xl/worksheets/sheet1.xml'); + + $this->assertStringNotContainsString('', $sheet); + $this->assertEquals([['Value'], ['=1+2']], $this->readSpreadsheet($path)); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_adds_a_bom_to_csv_files_so_excel_reads_thaana(): void + { + $path = TemporaryFile::path(Formats::CSV); + + IterableExport::make(['ނަން'], [['އަހުމަދު']])->writeTo($path); + + $this->assertStringStartsWith("\xEF\xBB\xBF", file_get_contents($path)); + + config()->set('exports.csv.bom', false); + + IterableExport::make(['ނަން'], [['އަހުމަދު']])->writeTo($path); + + $this->assertStringStartsWith('ނަން', file_get_contents($path)); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_uses_the_csv_delimiter_from_the_config(): void + { + config()->set('exports.csv.delimiter', ';'); + + $path = TemporaryFile::path(Formats::CSV); + + IterableExport::make(['A', 'B'], [[1, 2]])->writeTo($path); + + $this->assertStringContainsString("A;B\n1;2", file_get_contents($path)); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_sizes_xlsx_columns_to_their_widest_value(): void + { + $path = TemporaryFile::path(Formats::XLSX); + + IterableExport::make(['Name', 'Note'], [['އަހުމަދު', str_repeat('x', 100)]])->writeTo($path); + + $sheet = $this->readFromArchive($path, 'xl/worksheets/sheet1.xml'); + + $this->assertStringContainsString('assertStringContainsString('setLocale('dv'); + + $this->islandsExport()->writeTo($path); + + $sheet = $this->readFromArchive($path, 'xl/worksheets/sheet1.xml'); + + $this->assertStringContainsString('rightToLeft="true"', $sheet); + + app()->setLocale('en'); + + $this->islandsExport()->writeTo($path); + + $this->assertStringNotContainsString('rightToLeft="true"', $this->readFromArchive($path, 'xl/worksheets/sheet1.xml')); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_formats_dates_enums_and_objects_as_text(): void + { + $path = TemporaryFile::path(Formats::CSV); + + IterableExport::make(['Value'], [ + [now()->setDate(2026, 11, 4)->setTime(9, 30)], + [Formats::CSV], + [['island' => 'މާލެ']], + [true], + ])->writeTo($path); + + $this->assertEquals([ + ['Value'], + ['2026-11-04 09:30:00'], + ['csv'], + ['{"island":"މާލެ"}'], + ['1'], + ], $this->readSpreadsheet($path)); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_writes_null_as_an_empty_cell(): void + { + $path = TemporaryFile::path(Formats::XLSX); + + IterableExport::make(['A', 'B', 'C'], [['x', null, '']])->writeTo($path); + + $this->assertEquals([['A', 'B', 'C'], ['x', '', '']], $this->readSpreadsheet($path)); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_downloads_with_a_file_name_and_content_type(): void + { + $response = $this->islandsExport()->download('islands.csv'); + + $this->assertInstanceOf(BinaryFileResponse::class, $response); + $this->assertEquals('text/csv', $response->headers->get('Content-Type')); + $this->assertStringContainsString('attachment; filename=islands.csv', $response->headers->get('Content-Disposition')); + $this->assertEquals([ + ['Name', 'Atoll', 'Population'], + ['މާލެ', 'ކ', '252768'], + ['ހިތަދޫ', 'ސ', '13000'], + ], $this->readSpreadsheet($response->getFile()->getPathname(), 'csv')); + } + + #[Test] + public function it_adds_the_extension_and_a_timestamp_to_download_names(): void + { + $this->travelTo(now()->setDate(2026, 11, 4)->setTime(9, 30, 15)); + + $export = $this->islandsExport(); + + $this->assertEquals('islands-2026-11-04-093015.xlsx', $export->downloadName(null, Formats::XLSX)); + $this->assertEquals('atolls.ods', $export->downloadName('atolls', Formats::ODS)); + $this->assertEquals('atolls.csv', $export->downloadName('atolls.csv', Formats::CSV)); + } + + #[Test] + public function it_stores_exports_on_a_disk(): void + { + Storage::fake('exports'); + + config()->set('exports.disk', 'exports'); + + $path = $this->islandsExport()->store('reports/islands.csv'); + + $this->assertEquals('reports/islands.csv', $path); + Storage::disk('exports')->assertExists('reports/islands.csv'); + $this->assertStringContainsString('ހިތަދޫ', Storage::disk('exports')->get('reports/islands.csv')); + } + + #[Test] + public function it_stores_exports_in_the_configured_directory_by_default(): void + { + Storage::fake('local'); + + $this->travelTo(now()->setDate(2026, 11, 4)->setTime(9, 30, 15)); + + $path = $this->islandsExport()->store(disk: 'local', format: 'ods'); + + $this->assertEquals('exports/islands-2026-11-04-093015.ods', $path); + Storage::disk('local')->assertExists($path); + } + + #[Test] + public function it_resolves_formats(): void + { + $this->assertEquals(Formats::CSV, Formats::resolve('CSV')); + $this->assertEquals(Formats::ODS, Formats::resolve(null, 'report.ods')); + $this->assertEquals(Formats::XLSX, Formats::resolve(null, 'report')); + $this->assertEquals(Formats::CSV, Formats::resolve(Formats::CSV, 'report.ods')); + + config()->set('exports.format', 'csv'); + + $this->assertEquals(Formats::CSV, Formats::resolve()); + + $this->expectException(ValueError::class); + + Formats::resolve('pdf'); + } + + #[Test] + public function it_lists_the_enabled_formats(): void + { + config()->set('exports.formats', ['csv']); + + $this->assertEquals([Formats::CSV], Formats::enabled()); + $this->assertEquals('text/csv', Formats::CSV->mimeType()); + $this->assertEquals('Excel (XLSX)', Formats::XLSX->label()); + } + + #[Test] + public function it_counts_iterable_rows_when_it_can(): void + { + $this->assertEquals(2, $this->islandsExport()->totalRows()); + $this->assertNull(IterableExport::make(['A'], fn () => yield [1])->totalRows()); + $this->assertEquals(5, IterableExport::make(['A'], fn () => yield [1], total_rows: 5)->totalRows()); + } + + protected function islandsExport(): IterableExport + { + return IterableExport::make(['Name', 'Atoll', 'Population'], [ + ['މާލެ', 'ކ', 252768], + ['name' => 'ހިތަދޫ', 'atoll' => 'ސ', 'population' => 13000], + ], 'islands'); + } + + /** + * Numbers read back from a CSV are strings + * + * @param array> $rows + * @return array> + */ + protected function normalise(array $rows): array + { + return array_map(fn (array $row) => array_map( + fn (mixed $value) => is_string($value) && ctype_digit($value) ? (int) $value : $value, + $row, + ), $rows); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index e780537..d1903c0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,28 +2,78 @@ namespace Javaabu\Exports\Tests; -use Orchestra\Testbench\TestCase as BaseTestCase; use Javaabu\Exports\ExportsServiceProvider; use Javaabu\Exports\Tests\TestSupport\Providers\TestServiceProvider; +use OpenSpout\Reader\CSV\Reader as CsvReader; +use OpenSpout\Reader\ODS\Reader as OdsReader; +use OpenSpout\Reader\ReaderInterface; +use OpenSpout\Reader\XLSX\Reader as XlsxReader; +use Orchestra\Testbench\TestCase as BaseTestCase; +use ZipArchive; abstract class TestCase extends BaseTestCase { - - public function setUp(): void + protected function setUp(): void { parent::setUp(); $this->app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); - - $this->app['config']->set('session.serialization', 'php'); - } - protected function getPackageProviders($app) + protected function getPackageProviders($app): array { return [ ExportsServiceProvider::class, - TestServiceProvider::class + TestServiceProvider::class, ]; } + + /** + * Read every row of a CSV, XLSX or ODS file + * + * @return array> + */ + protected function readSpreadsheet(string $path, ?string $extension = null): array + { + $reader = $this->makeReader($extension ?? pathinfo($path, PATHINFO_EXTENSION)); + $reader->open($path); + + $rows = []; + + foreach ($reader->getSheetIterator() as $sheet) { + foreach ($sheet->getRowIterator() as $row) { + $rows[] = $row->toArray(); + } + + break; + } + + $reader->close(); + + return $rows; + } + + protected function makeReader(string $extension): ReaderInterface + { + return match ($extension) { + 'csv' => new CsvReader, + 'ods' => new OdsReader, + default => new XlsxReader, + }; + } + + /** + * Read a file inside an XLSX or ODS archive, e.g. `xl/worksheets/sheet1.xml` + */ + protected function readFromArchive(string $path, string $file): string + { + $zip = new ZipArchive; + $zip->open($path); + + $contents = (string) $zip->getFromName($file); + + $zip->close(); + + return $contents; + } } diff --git a/tests/TestSupport/Enums/UserStatuses.php b/tests/TestSupport/Enums/UserStatuses.php index f3e83f8..a004bf1 100644 --- a/tests/TestSupport/Enums/UserStatuses.php +++ b/tests/TestSupport/Enums/UserStatuses.php @@ -4,7 +4,7 @@ enum UserStatuses: string { - case Approved = 'approved'; - case Pending = 'pending'; - case Banned = 'banned'; + case APPROVED = 'approved'; + case PENDING = 'pending'; + case BANNED = 'banned'; } diff --git a/tests/TestSupport/Exports/UsersExport.php b/tests/TestSupport/Exports/UsersExport.php index d6bd3e3..2ca6033 100644 --- a/tests/TestSupport/Exports/UsersExport.php +++ b/tests/TestSupport/Exports/UsersExport.php @@ -7,7 +7,6 @@ class UsersExport extends ModelExport { - public function modelClass(): string { return User::class; diff --git a/tests/TestSupport/Models/Organization.php b/tests/TestSupport/Models/Organization.php index 9dd80c7..780bab3 100644 --- a/tests/TestSupport/Models/Organization.php +++ b/tests/TestSupport/Models/Organization.php @@ -12,7 +12,7 @@ class Organization extends Authenticatable protected static function newFactory() { - return new OrganizationFactory(); + return new OrganizationFactory; } public function getMorphClass() diff --git a/tests/TestSupport/Models/Role.php b/tests/TestSupport/Models/Role.php index 1dc2e6c..b09052f 100644 --- a/tests/TestSupport/Models/Role.php +++ b/tests/TestSupport/Models/Role.php @@ -12,7 +12,7 @@ class Role extends Authenticatable protected static function newFactory() { - return new RoleFactory(); + return new RoleFactory; } public function getMorphClass() diff --git a/tests/TestSupport/Models/User.php b/tests/TestSupport/Models/User.php index 0f8e5cf..8ef7f68 100644 --- a/tests/TestSupport/Models/User.php +++ b/tests/TestSupport/Models/User.php @@ -2,10 +2,10 @@ namespace Javaabu\Exports\Tests\TestSupport\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Javaabu\Exports\Tests\TestSupport\Enums\UserStatuses; use Javaabu\Exports\Tests\TestSupport\Factories\UserFactory; @@ -20,12 +20,12 @@ class User extends Authenticatable ]; protected $casts = [ - 'status' => Userstatuses::class, + 'status' => UserStatuses::class, ]; protected static function newFactory() { - return new UserFactory(); + return new UserFactory; } public function scopeSearch($query, $search) diff --git a/tests/TestSupport/Providers/TestServiceProvider.php b/tests/TestSupport/Providers/TestServiceProvider.php index d4fc7d6..1d09c7e 100644 --- a/tests/TestSupport/Providers/TestServiceProvider.php +++ b/tests/TestSupport/Providers/TestServiceProvider.php @@ -19,8 +19,5 @@ public function boot() /** * Register the application services. */ - public function register() - { - - } + public function register() {} } diff --git a/tests/TestSupport/database/2024_01_05_223412_create_roles_table.php b/tests/TestSupport/database/2024_01_05_223412_create_roles_table.php index 77317df..af5ae91 100644 --- a/tests/TestSupport/database/2024_01_05_223412_create_roles_table.php +++ b/tests/TestSupport/database/2024_01_05_223412_create_roles_table.php @@ -1,10 +1,11 @@ create(); - $export = new UsersExport(); - + $export = new UsersExport; $result = $export->query()->get(); $this->assertTrue($result->contains('id', $user->id)); } - public function test_it_can_determine_the_allowed_attributes(): void + #[Test] + public function it_can_determine_the_allowed_attributes(): void { - $export = new UsersExport(); + $export = new UsersExport; $this->assertEquals([ 'id', @@ -44,13 +46,13 @@ public function test_it_can_determine_the_allowed_attributes(): void ], $export->allowedAttributes()); } - public function test_it_only_includes_allowed_attributes(): void + #[Test] + public function it_only_includes_allowed_attributes(): void { $role = Role::factory()->create([ 'name' => 'Test Role', ]); - /** @var User $user */ $user = User::factory()->create([ 'role_id' => $role->id, 'is_admin' => true, @@ -66,7 +68,7 @@ public function test_it_only_includes_allowed_attributes(): void $user->organizations()->sync([$organization_1->id, $organization_2->id]); - $export = new UsersExport(); + $export = new UsersExport; $this->assertEquals([ $user->id, @@ -83,9 +85,10 @@ public function test_it_only_includes_allowed_attributes(): void ], $export->map($user)); } - public function test_it_can_generate_headings(): void + #[Test] + public function it_can_generate_headings(): void { - $export = new UsersExport(); + $export = new UsersExport; $this->assertEquals([ 'Id', From 7b5da35c03aae88799aee92785979f14940d0f27 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 13:24:00 +0500 Subject: [PATCH 02/13] Stream model exports in chunks with smarter defaults Rows are read with lazyById() (lazy() when the query is ordered) and eager loaded per chunk, so memory stays flat: the 1M-row benchmark (composer benchmark, and its own CI job) peaks at +4.4 MB for CSV and +3.3 MB for XLSX. ModelExport now picks readable defaults: no hidden attributes or secrets, foreign keys replaced by their BelongsTo relation, headings from the column, the model's exportHeadings(), validation attribute names, or a translated headline; enum labels, Yes/No, dates in the app timezone, JSON for arrays, related models by name. columns() takes dot notation and Column definitions. The v1 hooks (allowedAttributes, relationsToInclude, formatValue, headings, map) still work. Closes #2 Closes #3 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 22 + CHANGELOG.md | 5 + composer.json | 1 + config/exports.php | 36 ++ lang/dv.json | 8 +- lang/en.json | 3 +- phpunit.xml.dist | 10 +- src/Columns/Column.php | 117 +++++ src/Contracts/HasExportHeadings.php | 16 + src/ModelExport.php | 465 ++++++++++++++++-- tests/Benchmark/MillionRowsTest.php | 84 ++++ tests/Concerns/SeedsUsers.php | 36 ++ tests/Feature/StreamingTest.php | 146 ++++++ tests/TestSupport/Enums/Atolls.php | 17 + tests/TestSupport/Exports/IslandsExport.php | 14 + .../TestSupport/Exports/PlainUsersExport.php | 14 + tests/TestSupport/Models/Island.php | 24 + tests/TestSupport/Models/User.php | 15 +- .../2024_02_05_223412_create_users_table.php | 3 + ...2024_05_05_223412_create_islands_table.php | 25 + tests/Unit/ModelExportTest.php | 319 ++++++++++-- 21 files changed, 1292 insertions(+), 88 deletions(-) create mode 100644 src/Columns/Column.php create mode 100644 src/Contracts/HasExportHeadings.php create mode 100644 tests/Benchmark/MillionRowsTest.php create mode 100644 tests/Concerns/SeedsUsers.php create mode 100644 tests/Feature/StreamingTest.php create mode 100644 tests/TestSupport/Enums/Atolls.php create mode 100644 tests/TestSupport/Exports/IslandsExport.php create mode 100644 tests/TestSupport/Exports/PlainUsersExport.php create mode 100644 tests/TestSupport/Models/Island.php create mode 100644 tests/TestSupport/database/2024_05_05_223412_create_islands_table.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 074c4e8..22f021f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,28 @@ jobs: - name: Check code style run: vendor/bin/pint --test + benchmark: + runs-on: ubuntu-latest + + name: Benchmark (1M rows) + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + extensions: curl, mbstring, zip, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Export a million rows with flat memory + run: vendor/bin/phpunit --group benchmark + tests: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd7152..198ff52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,8 @@ All notable changes will be documented in this file. - Requires PHP 8.4 and Laravel 12 or 13. - Replaced maatwebsite/excel with OpenSpout: CSV, XLSX and ODS are streamed with flat memory. See `docs/upgrading.md`. +- Exports read with `lazyById()` (or `lazy()` for ordered queries), eager loading per chunk. A million rows export with a few MB of memory. +- Smarter defaults: hidden attributes, passwords and tokens are left out, foreign keys are replaced by their relation, headings come from validation attribute names, the model or a readable headline (translated), enums use their label, booleans are "Yes"/"No", dates are in the app timezone. +- `columns()` with dot-notation relations and `Column` definitions (label, format, computed value). +- `IterableExport` for rows that aren't models. +- CSV values that would run as formulas are escaped; XLSX strings are never written as formulas. XLSX columns are sized to fit, and sheets are right-to-left in Dhivehi. diff --git a/composer.json b/composer.json index 90bee97..4816871 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,7 @@ }, "scripts": { "test": "vendor/bin/phpunit", + "benchmark": "vendor/bin/phpunit --group benchmark", "test-coverage": "vendor/bin/phpunit --coverage-clover build/coverage.xml", "lint": "vendor/bin/pint --test", "format": "vendor/bin/pint" diff --git a/config/exports.php b/config/exports.php index 57d33ca..1830f94 100644 --- a/config/exports.php +++ b/config/exports.php @@ -56,16 +56,52 @@ 'chunk_size' => 1000, + /* + |-------------------------------------------------------------------------- + | Default columns + |-------------------------------------------------------------------------- + | + | When an export doesn't list its columns, it includes the model's table + | columns except hidden attributes and these names (patterns allowed). + | Timestamps (created_at, updated_at, deleted_at) are included unless + | timestamps is false. With resolve_foreign_keys, a foreign key such as + | role_id is replaced by its BelongsTo relation (role) when the model + | defines one, and the related model is shown by its name. + | + */ + + 'exclude' => [ + 'password', + 'remember_token', + '*_token', + '*_secret', + 'two_factor_recovery_codes', + 'api_key', + ], + + 'timestamps' => true, + + 'resolve_foreign_keys' => true, + /* |-------------------------------------------------------------------------- | Formatting |-------------------------------------------------------------------------- + | + | Dates are converted to this timezone (null means the app timezone) and + | written with datetime_format, or date_format for attributes cast to + | date. Lists (to-many relations) are joined with list_separator. + | */ + 'timezone' => null, + 'datetime_format' => 'Y-m-d H:i:s', 'date_format' => 'Y-m-d', + 'list_separator' => ', ', + /* |-------------------------------------------------------------------------- | Right-to-left sheets diff --git a/lang/dv.json b/lang/dv.json index 0967ef4..7a4a3fc 100644 --- a/lang/dv.json +++ b/lang/dv.json @@ -1 +1,7 @@ -{} +{ + "Yes": "އާނ", + "No": "ނޫން", + "ID": "އައިޑީ", + "Name": "ނަން", + "Email": "އީމެއިލް" +} diff --git a/lang/en.json b/lang/en.json index 0967ef4..2c63c08 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1 +1,2 @@ -{} +{ +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d5a979d..cf8b8f1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,16 +10,16 @@ tests + + + benchmark + + src - - - - - diff --git a/src/Columns/Column.php b/src/Columns/Column.php new file mode 100644 index 0000000..985490e --- /dev/null +++ b/src/Columns/Column.php @@ -0,0 +1,117 @@ +label(__('Role')); + * Column::make('created_at')->format(fn (Carbon $value) => $value->toDateString()); + * Column::make('full_address')->value(fn (User $user) => "{$user->island}, {$user->atoll}"); + * ``` + */ +class Column +{ + protected string|Closure|null $label = null; + + /** @var (Closure(mixed, Model): mixed)|null */ + protected ?Closure $format = null; + + /** @var (Closure(Model): mixed)|null */ + protected ?Closure $value = null; + + /** + * @param string $name an attribute (`email`), a relation (`organizations`), or a + * relation's attribute in dot notation (`userRole.name`) + */ + public function __construct( + public readonly string $name, + ) {} + + public static function make(string $name): static + { + return new static($name); + } + + /** + * The heading of the column + */ + public function label(string|Closure $label): static + { + $this->label = $label; + + return $this; + } + + /** + * Format the column's value yourself, instead of the export's formatValue() + * + * @param Closure(mixed, Model): mixed $callback receives the raw value and the model + */ + public function format(Closure $callback): static + { + $this->format = $callback; + + return $this; + } + + /** + * Compute the column's value from the model instead of reading an attribute + * + * @param Closure(Model): mixed $callback + */ + public function value(Closure $callback): static + { + $this->value = $callback; + + return $this; + } + + public function getLabel(): ?string + { + if ($this->label instanceof Closure) { + return (string) ($this->label)(); + } + + return $this->label; + } + + /** + * @return (Closure(mixed, Model): mixed)|null + */ + public function getFormatter(): ?Closure + { + return $this->format; + } + + /** + * @return (Closure(Model): mixed)|null + */ + public function getValueResolver(): ?Closure + { + return $this->value; + } + + /** + * The relation path of a dot-notation column: `userRole` for `userRole.name` + */ + public function relationPath(): ?string + { + return str_contains($this->name, '.') + ? Str::beforeLast($this->name, '.') + : null; + } + + /** + * The last segment of the name: `name` for `userRole.name` + */ + public function attribute(): string + { + return Str::afterLast($this->name, '.'); + } +} diff --git a/src/Contracts/HasExportHeadings.php b/src/Contracts/HasExportHeadings.php new file mode 100644 index 0000000..9779b52 --- /dev/null +++ b/src/Contracts/HasExportHeadings.php @@ -0,0 +1,16 @@ + __('National ID')]` + * + * @return array + */ + public function exportHeadings(): array; +} diff --git a/src/ModelExport.php b/src/ModelExport.php index b3a23e9..5e9509c 100644 --- a/src/ModelExport.php +++ b/src/ModelExport.php @@ -3,23 +3,48 @@ namespace Javaabu\Exports; use BackedEnum; +use Carbon\CarbonInterface; +use DateTimeInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Schema; use Illuminate\Support\LazyCollection; use Illuminate\Support\Str; +use Javaabu\Exports\Columns\Column; use Javaabu\Exports\Concerns\WritesFiles; use Javaabu\Exports\Contracts\Exportable; +use Javaabu\Exports\Contracts\HasExportHeadings; +use JsonSerializable; +use ReflectionMethod; +use ReflectionNamedType; +use Throwable; +use UnitEnum; /** * Exports the models of a query, one row per model + * + * Without configuration it includes the model's table columns, minus hidden attributes, + * secrets and foreign keys it can replace with their relation, with readable headings and + * formatted values. Override columns() to choose the columns yourself. */ abstract class ModelExport implements Exportable { use WritesFiles; + /** Attributes tried, in order, to show a related model */ + protected const array DISPLAY_ATTRIBUTES = ['admin_link_name', 'name', 'title', 'label']; + + /** @var array|null */ + protected ?array $resolved_columns = null; + + /** @var array */ + protected array $relation_names = []; + public function __construct( protected Builder|Relation|null $query = null, ) {} @@ -32,29 +57,56 @@ public function __construct( abstract public function modelClass(): string; /** + * The columns, in order: attribute names, relation names, dot notation + * (`userRole.name`) or Column instances + * + * @return array + */ + public function columns(): array + { + return $this->allowedAttributes(); + } + + /** + * The default columns: table columns that aren't hidden or excluded, foreign keys + * replaced by their relation, then relationsToInclude() + * * @return array */ public function allowedAttributes(): array { $model = $this->newModel(); + $included_relations = $this->relationsToInclude(); + $excluded = $this->excludedColumns($model, $included_relations); + + $columns = []; + + foreach ($this->tableColumns($model) as $column) { + if ($this->isExcluded($column, $excluded)) { + continue; + } - $attributes = array_values(array_diff( - Schema::connection($model->getConnectionName())->getColumnListing($model->getTable()), - $model->getHidden(), - )); + $columns[] = $this->foreignKeyRelation($model, $column, $included_relations) ?? $column; + } - return array_merge($attributes, $this->relationsToInclude()); + return array_values(array_unique(array_merge($columns, $included_relations))); } - public function query(): Builder|Relation + /** + * Relations added as columns after the table columns, shown by their display name + * + * @return array + */ + public function relationsToInclude(): array { - if ($this->query) { - return $this->query; - } + return []; + } - $query = $this->modelClass()::query(); + public function query(): Builder|Relation + { + $query = $this->query ?? $this->modelClass()::query(); - if ($relations = $this->relationsToInclude()) { + if ($relations = $this->eagerLoads()) { $query->with($relations); } @@ -62,11 +114,32 @@ public function query(): Builder|Relation } /** + * The relations the columns need, eager loaded for every chunk + * * @return array */ - public function relationsToInclude(): array + public function eagerLoads(): array { - return []; + $model = $this->newModel(); + $relations = $this->relationsToInclude(); + + foreach ($this->resolvedColumns() as $column) { + if ($column->getValueResolver()) { + continue; + } + + if ($path = $column->relationPath()) { + $relations[] = $path; + + continue; + } + + if ($this->isRelation($model, $column->name)) { + $relations[] = $column->name; + } + } + + return array_values(array_unique($relations)); } /** @@ -80,7 +153,8 @@ public function rows(): iterable } /** - * The models, loaded a chunk at a time + * The models, loaded a chunk at a time: by key with lazyById(), or with lazy() + * when the query has its own order * * @return LazyCollection */ @@ -120,13 +194,66 @@ public function filename(): string */ public function map(Model $model): array { - $attributes = $model->only($this->allowedAttributes()); + $row = []; + + foreach ($this->resolvedColumns() as $column) { + $row[] = $this->columnValue($model, $column); + } + + return $row; + } + + /** + * Format a value for the file. Override it for one attribute and call the parent for the rest. + */ + public function formatValue(Model $model, string $attribute, mixed $value): mixed + { + if ($value instanceof UnitEnum) { + return $this->getEnumLabel($model, $attribute, $value); + } + + if ($this->isAdminModelCollection($model, $attribute, $value)) { + return $value->map(fn (Model $item) => $this->displayName($item))->implode($this->listSeparator()); + } + + if ($value instanceof Model) { + return $this->displayName($value); + } + + if ($value instanceof Collection) { + return $value->map(fn (mixed $item) => $this->formatValue($model, $attribute, $item)) + ->filter(fn (mixed $item) => $item !== null && $item !== '') + ->implode($this->listSeparator()); + } + + if (is_bool($value)) { + return $value ? __('Yes') : __('No'); + } + + if ($value instanceof DateTimeInterface) { + return $this->formatDate($model, $attribute, $value); + } + + if (is_array($value) || $value instanceof JsonSerializable) { + return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + return $value; + } - foreach ($attributes as $attribute => $value) { - $attributes[$attribute] = $this->formatValue($model, $attribute, $value); + public function getEnumLabel(Model $model, string $attribute, UnitEnum $value): string + { + foreach (['getLabel', 'label'] as $method) { + if (method_exists($value, $method)) { + return (string) $value->{$method}(); + } } - return array_values($attributes); + $name = $value instanceof BackedEnum && is_string($value->value) + ? $value->value + : $value->name; + + return __(Str::of($name)->lower()->headline()->toString()); } public function isAdminModel(Model $model, string $attribute, mixed $value): bool @@ -140,8 +267,12 @@ public function isAdminModelCollection(Model $model, string $attribute, mixed $v return false; } + if ($value->isEmpty()) { + return false; + } + foreach ($value as $item) { - if (! $this->isAdminModel($model, $attribute, $item)) { + if (! $item instanceof Model) { return false; } } @@ -149,49 +280,299 @@ public function isAdminModelCollection(Model $model, string $attribute, mixed $v return true; } - public function getEnumLabel(Model $model, string $attribute, mixed $value): ?string + /** + * @return array + */ + public function headings(): array { - if ($value instanceof BackedEnum) { - return method_exists($value, 'getLabel') ? $value->getLabel() : $value->name; + $model = $this->newModel(); + + $model_headings = $model instanceof HasExportHeadings + ? $model->exportHeadings() + : []; + + return array_map( + fn (Column $column) => $this->heading($column, $model_headings), + $this->resolvedColumns(), + ); + } + + /** + * The columns as Column instances + * + * @return array + */ + public function resolvedColumns(): array + { + return $this->resolved_columns ??= array_map( + fn (string|Column $column) => is_string($column) ? Column::make($column) : $column, + array_values($this->columns()), + ); + } + + /** + * The name that shows a related model: its admin_link_name, name, title or label, or its key + */ + public function displayName(Model $model): mixed + { + foreach (static::DISPLAY_ATTRIBUTES as $attribute) { + $value = $attribute === 'admin_link_name' + ? (method_exists($model, 'getAdminLinkNameAttribute') ? $model->admin_link_name : null) + : $model->getAttribute($attribute); + + if ($value === null || $value === '') { + continue; + } + + return $value instanceof UnitEnum + ? $this->getEnumLabel($model, $attribute, $value) + : $value; } - return ''; + return $model->getKey(); } - public function formatValue(Model $model, string $attribute, mixed $value): mixed + protected function columnValue(Model $model, Column $column): mixed { - if ($value instanceof BackedEnum) { - return $this->getEnumLabel($model, $attribute, $value); + $value = $column->getValueResolver() + ? ($column->getValueResolver())($model) + : $this->rawValue($model, $column->name); + + if ($formatter = $column->getFormatter()) { + return $formatter($value, $model); } - if ($this->isAdminModelCollection($model, $attribute, $value)) { - return $value->implode('admin_link_name', ','); + return $this->formatValue($model, $column->name, $value); + } + + /** + * The value of an attribute, a relation or a dot-notation path. Paths through + * to-many relations give a collection of values. + */ + protected function rawValue(Model $model, string $path): mixed + { + $value = $model; + + foreach (explode('.', $path) as $segment) { + $value = $this->segmentValue($value, $segment); + + if ($value === null) { + return null; + } } - if ($this->isAdminModel($model, $attribute, $value)) { - return $value->admin_link_name; + return $value; + } + + protected function segmentValue(mixed $value, string $segment): mixed + { + if ($value instanceof Collection) { + return $value->map(fn (mixed $item) => $this->segmentValue($item, $segment)) + ->flatten(1) + ->filter(fn (mixed $item) => $item !== null) + ->values(); } - if (is_bool($value)) { - return $value ? 'True' : 'False'; + if ($value instanceof Model) { + return $value->getAttribute($segment); } - return $value; + return data_get($value, $segment); + } + + /** + * @param array $model_headings + */ + protected function heading(Column $column, array $model_headings): string + { + if (($label = $column->getLabel()) !== null) { + return $label; + } + + if (isset($model_headings[$column->name])) { + return $model_headings[$column->name]; + } + + $key = 'validation.attributes.' . Str::snake(str_replace('.', '_', $column->name)); + + if (trans()->has($key)) { + return (string) __($key); + } + + return __($this->headline($column)); + } + + /** + * `Email`, `Role ID` for `role_id`, `User Role` for `userRole.name`, `User Role Email` for `userRole.email` + */ + protected function headline(Column $column): string + { + $path = $column->relationPath(); + $attribute = $column->attribute(); + + $name = $path && in_array($attribute, static::DISPLAY_ATTRIBUTES, true) + ? $path + : $column->name; + + $headline = Str::headline(str_replace('.', '_', $name)); + + return preg_replace('/\bId$/', 'ID', $headline); + } + + protected function formatDate(Model $model, string $attribute, DateTimeInterface $value): string + { + $date = $value instanceof CarbonInterface + ? $value->copy() + : Carbon::instance($value); + + $date->setTimezone((string) (config('exports.timezone') ?: config('app.timezone', 'UTC'))); + + $cast = $model->getCasts()[$attribute] ?? null; + + $format = in_array(Str::before((string) $cast, ':'), ['date', 'immutable_date'], true) + ? config('exports.date_format', 'Y-m-d') + : config('exports.datetime_format', 'Y-m-d H:i:s'); + + return $date->format((string) $format); + } + + protected function listSeparator(): string + { + return (string) config('exports.list_separator', ', '); } /** * @return array */ - public function headings(): array + protected function tableColumns(Model $model): array + { + return Schema::connection($model->getConnectionName())->getColumnListing($model->getTable()); + } + + /** + * Column names and patterns left out of the default columns + * + * @param array $included_relations + * @return array + */ + protected function excludedColumns(Model $model, array $included_relations): array + { + $excluded = array_merge($model->getHidden(), config('exports.exclude', [])); + + if (! config('exports.timestamps', true)) { + $excluded = array_merge($excluded, array_filter([ + $model->getCreatedAtColumn(), + $model->getUpdatedAtColumn(), + method_exists($model, 'getDeletedAtColumn') ? $model->getDeletedAtColumn() : null, + ])); + } + + foreach ($included_relations as $relation_name) { + $relation = $this->relation($model, $relation_name); + + if ($relation instanceof MorphTo) { + $excluded[] = $relation->getForeignKeyName(); + $excluded[] = $relation->getMorphType(); + + continue; + } + + if ($relation instanceof BelongsTo) { + $excluded[] = $relation->getForeignKeyName(); + } + } + + return $excluded; + } + + /** + * @param array $excluded + */ + protected function isExcluded(string $column, array $excluded): bool + { + foreach ($excluded as $pattern) { + if (Str::is($pattern, $column)) { + return true; + } + } + + return false; + } + + /** + * The BelongsTo relation that replaces a foreign key column, `role` for `role_id`, + * when `exports.resolve_foreign_keys` is on and the model defines it + * + * @param array $included_relations + */ + protected function foreignKeyRelation(Model $model, string $column, array $included_relations): ?string { - return array_map(function (string $slug) { - return Str::of($slug) - ->camel() - ->snake() - ->replace('_', ' ') - ->title() - ->toString(); - }, $this->allowedAttributes()); + if (! config('exports.resolve_foreign_keys', true)) { + return null; + } + + if (! str_ends_with($column, '_id')) { + return null; + } + + $relation_name = Str::camel(Str::beforeLast($column, '_id')); + + if (in_array($relation_name, $included_relations, true)) { + return null; + } + + $relation = $this->relation($model, $relation_name); + + if (! $relation instanceof BelongsTo || $relation instanceof MorphTo) { + return null; + } + + if ($relation->getForeignKeyName() !== $column) { + return null; + } + + return $relation_name; + } + + protected function isRelation(Model $model, string $name): bool + { + return $this->relation_names[$name] ??= $this->relation($model, $name) !== null; + } + + /** + * The relation a model method returns. Only public, non-static methods without required + * parameters, declared by the app's model rather than by Laravel, are called, and only when + * their return type is a relation or isn't declared. + */ + protected function relation(Model $model, string $name): ?Relation + { + if (! method_exists($model, $name)) { + return null; + } + + $method = new ReflectionMethod($model, $name); + + if (! $method->isPublic() || $method->isStatic() || $method->getNumberOfRequiredParameters() > 0) { + return null; + } + + if (str_starts_with($method->getDeclaringClass()->getName(), 'Illuminate\\')) { + return null; + } + + $return_type = $method->getReturnType(); + + if ($return_type instanceof ReflectionNamedType && ! is_a($return_type->getName(), Relation::class, true)) { + return null; + } + + try { + $relation = $model->{$name}(); + } catch (Throwable) { + return null; + } + + return $relation instanceof Relation ? $relation : null; } protected function newModel(): Model diff --git a/tests/Benchmark/MillionRowsTest.php b/tests/Benchmark/MillionRowsTest.php new file mode 100644 index 0000000..ebc8c7f --- /dev/null +++ b/tests/Benchmark/MillionRowsTest.php @@ -0,0 +1,84 @@ +database_path = sys_get_temp_dir() . '/exports-benchmark-' . getmypid() . '.sqlite'; + + touch($this->database_path); + + parent::setUp(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + @unlink($this->database_path); + } + + protected function defineEnvironment($app): void + { + $app['config']->set('database.connections.sqlite.database', $this->database_path); + } + + #[Test] + public function it_exports_a_million_rows_with_flat_memory(): void + { + $rows = (int) (getenv('EXPORTS_BENCHMARK_ROWS') ?: 1_000_000); + + $this->artisan('migrate')->run(); + $this->seedUsers($rows, 2000); + + foreach ([Formats::CSV, Formats::XLSX] as $format) { + $path = TemporaryFile::path($format); + + gc_collect_cycles(); + $before = memory_get_usage(); + memory_reset_peak_usage(); + $started = microtime(true); + + $count = UsersExport::make()->writeTo($path); + + $peak = memory_get_peak_usage() - $before; + $seconds = microtime(true) - $started; + $size = filesize($path); + + TemporaryFile::delete($path); + + fwrite(STDERR, sprintf( + "\n%s: %s rows in %.1fs, peak memory +%.1f MB, file %.1f MB", + $format->value, + number_format($count), + $seconds, + $peak / 1024 / 1024, + $size / 1024 / 1024, + )); + + $this->assertEquals($rows, $count); + $this->assertLessThan(32 * 1024 * 1024, $peak); + } + } +} diff --git a/tests/Concerns/SeedsUsers.php b/tests/Concerns/SeedsUsers.php new file mode 100644 index 0000000..c2da7bc --- /dev/null +++ b/tests/Concerns/SeedsUsers.php @@ -0,0 +1,36 @@ +toDateTimeString(); + $start = (int) DB::table('users')->max('id'); + + for ($offset = $start; $offset < $start + $count; $offset += $batch_size) { + $rows = []; + + for ($i = $offset; $i < min($offset + $batch_size, $start + $count); $i++) { + $rows[] = [ + 'email' => "user{$i}@example.mv", + 'name' => "އަހުމަދު {$i}", + 'password' => 'secret', + 'is_admin' => $i % 2, + 'role_id' => $role_id, + 'status' => 'approved', + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + DB::table('users')->insert($rows); + } + } +} diff --git a/tests/Feature/StreamingTest.php b/tests/Feature/StreamingTest.php new file mode 100644 index 0000000..830fd83 --- /dev/null +++ b/tests/Feature/StreamingTest.php @@ -0,0 +1,146 @@ +set('exports.chunk_size', 10); + + $this->seedUsers(35); + + DB::enableQueryLog(); + + $count = UsersExport::make()->writeTo($path = TemporaryFile::path(Formats::CSV)); + + $user_queries = collect(DB::getQueryLog()) + ->filter(fn (array $query) => str_starts_with($query['query'], 'select * from "users"')); + + $this->assertEquals(35, $count); + $this->assertCount(4, $user_queries); + $this->assertStringContainsString('"users"."id" > ?', $user_queries->last()['query']); + $this->assertCount(36, $this->readSpreadsheet($path)); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_eager_loads_relations_for_every_chunk(): void + { + config()->set('exports.chunk_size', 10); + + $role = Role::factory()->create(['name' => 'ވެރިޔާ']); + + $this->seedUsers(25, role_id: $role->id); + + DB::enableQueryLog(); + + UsersExport::make()->writeTo($path = TemporaryFile::path(Formats::CSV)); + + $role_queries = collect(DB::getQueryLog()) + ->filter(fn (array $query) => str_starts_with($query['query'], 'select * from "roles"')); + + $this->assertCount(3, $role_queries); + $this->assertEquals('ވެރިޔާ', $this->readSpreadsheet($path)[25][10]); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_keeps_the_order_of_an_ordered_query(): void + { + config()->set('exports.chunk_size', 2); + + User::factory()->create(['name' => 'ބ']); + User::factory()->create(['name' => 'އ']); + User::factory()->create(['name' => 'ދ']); + + UsersExport::make(User::query()->orderBy('name', 'desc'))->writeTo($path = TemporaryFile::path(Formats::CSV)); + + $names = array_column(array_slice($this->readSpreadsheet($path), 1), 2); + + // SQLite compares by code point: ދ (U+078B), އ (U+0787), ބ (U+0784) + $this->assertEquals(['ދ', 'އ', 'ބ'], $names); + + TemporaryFile::delete($path); + } + + #[Test] + public function it_exports_a_relation_query(): void + { + $role = Role::factory()->create(); + + User::factory()->count(2)->create(['role_id' => $role->id]); + User::factory()->create(); + + $export = UsersExport::make($role->hasMany(User::class, 'role_id')); + + $this->assertEquals(2, $export->totalRows()); + $this->assertCount(2, iterator_to_array($export->rows(), false)); + } + + #[Test] + public function it_counts_the_rows_of_the_query(): void + { + $this->seedUsers(12); + + $this->assertEquals(12, UsersExport::make()->totalRows()); + $this->assertEquals(3, UsersExport::make(User::query()->where('id', '<=', 3))->totalRows()); + } + + #[Test] + public function it_uses_flat_memory_however_many_rows_there_are(): void + { + config()->set('exports.chunk_size', 500); + + $this->seedUsers(2000); + $small = $this->peakMemoryOfExport(Formats::XLSX); + + $this->seedUsers(18000); + $large = $this->peakMemoryOfExport(Formats::XLSX); + + $this->assertLessThan( + 4 * 1024 * 1024, + $large - $small, + "Exporting 20,000 rows used {$this->megabytes($large)} MB at peak, against {$this->megabytes($small)} MB for 2,000.", + ); + } + + protected function peakMemoryOfExport(Formats $format): int + { + $path = TemporaryFile::path($format); + + gc_collect_cycles(); + $before = memory_get_usage(); + memory_reset_peak_usage(); + + UsersExport::make()->writeTo($path); + + $peak = memory_get_peak_usage() - $before; + + TemporaryFile::delete($path); + + return $peak; + } + + protected function megabytes(int $bytes): string + { + return number_format($bytes / 1024 / 1024, 1); + } +} diff --git a/tests/TestSupport/Enums/Atolls.php b/tests/TestSupport/Enums/Atolls.php new file mode 100644 index 0000000..725af26 --- /dev/null +++ b/tests/TestSupport/Enums/Atolls.php @@ -0,0 +1,17 @@ + 'ކ', + self::SEENU => 'ސ', + }; + } +} diff --git a/tests/TestSupport/Exports/IslandsExport.php b/tests/TestSupport/Exports/IslandsExport.php new file mode 100644 index 0000000..2bb7eee --- /dev/null +++ b/tests/TestSupport/Exports/IslandsExport.php @@ -0,0 +1,14 @@ + Atolls::class, + 'inhabited' => 'boolean', + ]; + + public function exportHeadings(): array + { + return [ + 'name' => 'ރަށުގެ ނަން', + ]; + } +} diff --git a/tests/TestSupport/Models/User.php b/tests/TestSupport/Models/User.php index 8ef7f68..e797c2f 100644 --- a/tests/TestSupport/Models/User.php +++ b/tests/TestSupport/Models/User.php @@ -21,9 +21,12 @@ class User extends Authenticatable protected $casts = [ 'status' => UserStatuses::class, + 'is_admin' => 'boolean', + 'birthday' => 'date', + 'settings' => 'array', ]; - protected static function newFactory() + protected static function newFactory(): UserFactory { return new UserFactory; } @@ -43,8 +46,18 @@ public function userRole(): BelongsTo return $this->belongsTo(Role::class, 'role_id'); } + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } + public function organizations(): BelongsToMany { return $this->belongsToMany(Organization::class); } + + public function greeting(): string + { + return "ހެލޯ {$this->name}"; + } } diff --git a/tests/TestSupport/database/2024_02_05_223412_create_users_table.php b/tests/TestSupport/database/2024_02_05_223412_create_users_table.php index 0e82848..1d90b1a 100644 --- a/tests/TestSupport/database/2024_02_05_223412_create_users_table.php +++ b/tests/TestSupport/database/2024_02_05_223412_create_users_table.php @@ -16,6 +16,9 @@ public function up() $table->boolean('is_admin')->default(false); $table->foreignId('role_id')->nullable()->constrained()->nullOnDelete(); $table->string('status')->index(); + $table->date('birthday')->nullable(); + $table->json('settings')->nullable(); + $table->string('api_token')->nullable(); $table->timestamps(); $table->softDeletes(); }); diff --git a/tests/TestSupport/database/2024_05_05_223412_create_islands_table.php b/tests/TestSupport/database/2024_05_05_223412_create_islands_table.php new file mode 100644 index 0000000..e8e97c3 --- /dev/null +++ b/tests/TestSupport/database/2024_05_05_223412_create_islands_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('atoll'); + $table->boolean('inhabited')->default(true); + $table->unsignedInteger('population')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('islands'); + } +}; diff --git a/tests/Unit/ModelExportTest.php b/tests/Unit/ModelExportTest.php index 1323bc8..fc10141 100644 --- a/tests/Unit/ModelExportTest.php +++ b/tests/Unit/ModelExportTest.php @@ -2,9 +2,17 @@ namespace Javaabu\Exports\Tests\Unit; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Testing\RefreshDatabase; +use Javaabu\Exports\Columns\Column; +use Javaabu\Exports\ModelExport; use Javaabu\Exports\Tests\TestCase; +use Javaabu\Exports\Tests\TestSupport\Enums\Atolls; +use Javaabu\Exports\Tests\TestSupport\Enums\UserStatuses; +use Javaabu\Exports\Tests\TestSupport\Exports\IslandsExport; +use Javaabu\Exports\Tests\TestSupport\Exports\PlainUsersExport; use Javaabu\Exports\Tests\TestSupport\Exports\UsersExport; +use Javaabu\Exports\Tests\TestSupport\Models\Island; use Javaabu\Exports\Tests\TestSupport\Models\Organization; use Javaabu\Exports\Tests\TestSupport\Models\Role; use Javaabu\Exports\Tests\TestSupport\Models\User; @@ -19,89 +27,324 @@ public function it_can_query_the_model(): void { $user = User::factory()->create(); - $export = new UsersExport; - - $result = $export->query()->get(); + $result = UsersExport::make()->query()->get(); $this->assertTrue($result->contains('id', $user->id)); } #[Test] - public function it_can_determine_the_allowed_attributes(): void + public function it_includes_table_columns_without_hidden_attributes_and_secrets(): void { - $export = new UsersExport; + $this->assertEquals([ + 'id', + 'email', + 'name', + 'is_admin', + 'role', + 'status', + 'birthday', + 'settings', + 'created_at', + 'updated_at', + 'deleted_at', + ], PlainUsersExport::make()->allowedAttributes()); + } + #[Test] + public function it_replaces_foreign_keys_with_included_relations(): void + { $this->assertEquals([ 'id', 'email', 'name', 'is_admin', - 'role_id', 'status', + 'birthday', + 'settings', 'created_at', 'updated_at', 'deleted_at', 'userRole', 'organizations', - ], $export->allowedAttributes()); + ], UsersExport::make()->allowedAttributes()); } #[Test] - public function it_only_includes_allowed_attributes(): void + public function it_can_keep_foreign_keys_and_leave_out_timestamps(): void { - $role = Role::factory()->create([ - 'name' => 'Test Role', - ]); + config()->set('exports.resolve_foreign_keys', false); + config()->set('exports.timestamps', false); + + $this->assertEquals([ + 'id', + 'email', + 'name', + 'is_admin', + 'role_id', + 'status', + 'birthday', + 'settings', + ], PlainUsersExport::make()->allowedAttributes()); + } + + #[Test] + public function it_excludes_the_configured_columns(): void + { + config()->set('exports.exclude', ['email', 'birth*']); + + $columns = PlainUsersExport::make()->allowedAttributes(); + + $this->assertNotContains('email', $columns); + $this->assertNotContains('birthday', $columns); + $this->assertContains('api_token', $columns); + } + + #[Test] + public function it_formats_values_for_people(): void + { + $role = Role::factory()->create(['name' => 'ވެރިޔާ']); $user = User::factory()->create([ - 'role_id' => $role->id, + 'email' => 'ahmed@example.mv', + 'name' => 'އަހުމަދު', 'is_admin' => true, + 'role_id' => $role->id, + 'status' => UserStatuses::APPROVED, + 'birthday' => '1990-05-01', + 'settings' => ['theme' => 'dark', 'island' => 'މާލެ'], ]); - $organization_1 = Organization::factory()->create([ - 'name' => 'Organization 1', - ]); + $user->forceFill(['created_at' => '2026-11-04 04:30:00', 'updated_at' => '2026-11-04 04:30:00'])->save(); - $organization_2 = Organization::factory()->create([ - 'name' => 'Organization 2', + $user->organizations()->sync([ + Organization::factory()->create(['name' => 'ޖާވާބު'])->id, + Organization::factory()->create(['name' => 'ސިފައިން'])->id, ]); - $user->organizations()->sync([$organization_1->id, $organization_2->id]); - - $export = new UsersExport; + $export = UsersExport::make(); + $row = $export->map($export->query()->first()); $this->assertEquals([ $user->id, - $user->email, - $user->name, - 'True', - $user->role_id, - $user->status->name, - $user->created_at, - $user->updated_at, - $user->deleted_at, - 'Test Role', - 'Organization 1,Organization 2', - ], $export->map($user)); + 'ahmed@example.mv', + 'އަހުމަދު', + 'Yes', + 'Approved', + '1990-05-01', + '{"theme":"dark","island":"މާލެ"}', + '2026-11-04 04:30:00', + '2026-11-04 04:30:00', + null, + 'ވެރިޔާ', + 'ޖާވާބު, ސިފައިން', + ], $row); + } + + #[Test] + public function it_converts_dates_to_the_configured_timezone(): void + { + config()->set('exports.timezone', 'Indian/Maldives'); + + $user = User::factory()->create(); + $user->forceFill(['created_at' => '2026-11-04 04:30:00'])->save(); + + $export = new class extends PlainUsersExport + { + public function columns(): array + { + return ['created_at']; + } + }; + + $this->assertEquals(['2026-11-04 09:30:00'], $export->map($user->fresh())); } #[Test] - public function it_can_generate_headings(): void + public function it_uses_enum_labels(): void { - $export = new UsersExport; + $island = Island::create(['name' => 'ހިތަދޫ', 'atoll' => Atolls::SEENU, 'inhabited' => false]); + $export = IslandsExport::make(); + + $this->assertEquals('ސ', $export->map($island)[2]); + $this->assertEquals('No', $export->map($island)[3]); + } + + #[Test] + public function it_generates_readable_headings(): void + { $this->assertEquals([ - 'Id', + 'ID', 'Email', 'Name', 'Is Admin', - 'Role Id', + 'Role', 'Status', + 'Birthday', + 'Settings', 'Created At', 'Updated At', 'Deleted At', - 'User Role', - 'Organizations', - ], $export->headings()); + ], PlainUsersExport::make()->headings()); + } + + #[Test] + public function it_takes_headings_from_validation_attribute_names(): void + { + app('translator')->addLines(['validation.attributes.email' => 'އީމެއިލް'], 'en'); + + $this->assertEquals('އީމެއިލް', PlainUsersExport::make()->headings()[1]); + } + + #[Test] + public function it_takes_headings_from_the_model(): void + { + $this->assertEquals( + ['ID', 'ރަށުގެ ނަން', 'Atoll', 'Inhabited', 'Population', 'Created At', 'Updated At'], + IslandsExport::make()->headings(), + ); + } + + #[Test] + public function it_translates_headings_and_values(): void + { + app()->setLocale('dv'); + + $island = Island::create(['name' => 'މާލެ', 'atoll' => Atolls::KAAFU])->fresh(); + + $export = IslandsExport::make(); + + $this->assertEquals('ނަން', PlainUsersExport::make()->headings()[2]); + $this->assertEquals('އާނ', $export->map($island)[3]); + } + + #[Test] + public function it_supports_column_definitions(): void + { + $role = Role::factory()->create(['name' => 'ވެރިޔާ']); + + $user = User::factory()->create(['name' => 'އައިޝާ', 'role_id' => $role->id, 'birthday' => '1995-01-31']); + $user->organizations()->sync([ + Organization::factory()->create(['name' => 'ޖާވާބު'])->id, + Organization::factory()->create(['name' => 'ސިފައިން'])->id, + ]); + + $export = new class extends PlainUsersExport + { + public function columns(): array + { + return [ + 'name', + 'userRole.name', + Column::make('organizations.name')->label('ޖަމްޢިއްޔާތައް'), + Column::make('birthday')->format(fn ($value) => $value->format('d/m/Y')), + Column::make('greeting')->label('Greeting')->value(fn (User $user) => $user->greeting()), + ]; + } + }; + + $this->assertEquals(['userRole', 'organizations'], $export->eagerLoads()); + $this->assertEquals(['Name', 'User Role', 'ޖަމްޢިއްޔާތައް', 'Birthday', 'Greeting'], $export->headings()); + $this->assertEquals( + ['އައިޝާ', 'ވެރިޔާ', 'ޖާވާބު, ސިފައިން', '31/01/1995', 'ހެލޯ އައިޝާ'], + $export->map($export->query()->first()), + ); + } + + #[Test] + public function it_names_non_display_attributes_of_relations_in_headings(): void + { + $export = new class extends PlainUsersExport + { + public function columns(): array + { + return ['organizations.id', 'role_id']; + } + }; + + $this->assertEquals(['Organizations ID', 'Role ID'], $export->headings()); + } + + #[Test] + public function it_adds_eager_loads_to_a_given_query(): void + { + $export = UsersExport::make(User::query()->where('is_admin', true)); + + $this->assertEquals(['userRole', 'organizations'], array_keys($export->query()->getEagerLoads())); + } + + #[Test] + public function it_shows_related_models_without_a_name_by_their_key(): void + { + $user = User::factory()->create(); + + $export = new class extends PlainUsersExport + { + public function columns(): array + { + return ['name']; + } + }; + + $this->assertEquals($user->id, $export->displayName($user->setAttribute('name', null))); + } + + #[Test] + public function it_keeps_v1_overrides_working(): void + { + $role = Role::factory()->create(['name' => 'ވެރިޔާ']); + $user = User::factory()->create(['name' => 'އަހުމަދު', 'role_id' => $role->id]); + + $export = new class extends UsersExport + { + public function allowedAttributes(): array + { + return ['name', 'userRole']; + } + + public function formatValue($model, string $attribute, mixed $value): mixed + { + if ($attribute === 'userRole' && $value instanceof Model) { + return "#{$value->id}"; + } + + return parent::formatValue($model, $attribute, $value); + } + + public function headings(): array + { + return ['ނަން', 'ރޯލް']; + } + }; + + $this->assertEquals(['ނަން', 'ރޯލް'], $export->headings()); + $this->assertEquals(['އަހުމަދު', "#{$role->id}"], $export->map($user)); + } + + #[Test] + public function it_does_not_call_methods_that_are_not_relations(): void + { + $export = new class extends PlainUsersExport + { + public function columns(): array + { + return ['name', 'greeting', 'delete']; + } + }; + + $this->assertEquals([], $export->eagerLoads()); + } + + #[Test] + public function it_names_the_file_after_the_model(): void + { + $this->assertEquals('users', UsersExport::make()->filename()); + $this->assertEquals('islands', IslandsExport::make()->filename()); + } + + #[Test] + public function it_can_be_made_from_any_model_export(): void + { + $this->assertInstanceOf(ModelExport::class, UsersExport::make(User::query())); } } From 686466b3c0550e126f470df8c1d03162ec821ae5 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 13:30:10 +0500 Subject: [PATCH 03/13] Queue exports with an Export model, progress, notifications and pruning queue() creates an Export (uuid, user morph, format, status, rows, disk, path, size, expiry) and dispatches ProcessExport. The job streams the whole file in one go (XLSX and ODS can't be appended to from parallel jobs), saves progress every chunk, stops when the export is cancelled, moves the file to the configured disk under exports/{uuid}/, and notifies the user with a signed link that expires with the file. Builders passed to an export are serialized as SQL, bindings, order and eager loads (closures included) and rebuilt in the job; models held by an export are serialized as identifiers. exports:prune deletes expired exports, and stuck ones older than the expiry period, with their files. Closes #4 Refs #5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + composer.json | 6 + config/exports.php | 90 +++++ ...2026_11_04_000000_create_exports_table.php | 54 +++ lang/dv.json | 4 +- src/Commands/PruneExportsCommand.php | 43 +++ src/Concerns/WritesFiles.php | 63 +++- src/Contracts/Exportable.php | 5 + src/Enums/ExportStatuses.php | 39 +++ src/Events/ExportCancelled.php | 20 ++ src/Events/ExportCompleted.php | 20 ++ src/Events/ExportFailed.php | 20 ++ src/Events/ExportProgressed.php | 20 ++ src/Events/ExportStarted.php | 20 ++ src/Exceptions/ExportCancelledException.php | 10 + src/Exports.php | 121 +++++++ src/ExportsServiceProvider.php | 17 + src/IterableExport.php | 6 + src/Jobs/ProcessExport.php | 183 +++++++++++ src/ModelExport.php | 76 ++++- src/Models/Export.php | 309 ++++++++++++++++++ .../ExportCompletedNotification.php | 62 ++++ .../ExportFailedNotification.php | 48 +++ src/Support/SerializableQuery.php | 87 +++++ src/Support/TemporaryFile.php | 27 ++ tests/Feature/ExportModelTest.php | 177 ++++++++++ tests/Feature/QueuedExportsTest.php | 299 +++++++++++++++++ tests/TestSupport/Exports/FailingExport.php | 15 + .../TestSupport/Exports/OwnedUsersExport.php | 14 + tests/TestSupport/Models/User.php | 2 + .../Providers/TestServiceProvider.php | 20 +- 31 files changed, 1853 insertions(+), 27 deletions(-) create mode 100644 database/migrations/2026_11_04_000000_create_exports_table.php create mode 100644 src/Commands/PruneExportsCommand.php create mode 100644 src/Enums/ExportStatuses.php create mode 100644 src/Events/ExportCancelled.php create mode 100644 src/Events/ExportCompleted.php create mode 100644 src/Events/ExportFailed.php create mode 100644 src/Events/ExportProgressed.php create mode 100644 src/Events/ExportStarted.php create mode 100644 src/Exceptions/ExportCancelledException.php create mode 100644 src/Exports.php create mode 100644 src/Jobs/ProcessExport.php create mode 100644 src/Models/Export.php create mode 100644 src/Notifications/ExportCompletedNotification.php create mode 100644 src/Notifications/ExportFailedNotification.php create mode 100644 src/Support/SerializableQuery.php create mode 100644 tests/Feature/ExportModelTest.php create mode 100644 tests/Feature/QueuedExportsTest.php create mode 100644 tests/TestSupport/Exports/FailingExport.php create mode 100644 tests/TestSupport/Exports/OwnedUsersExport.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 198ff52..81b4e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,6 @@ All notable changes will be documented in this file. - `columns()` with dot-notation relations and `Column` definitions (label, format, computed value). - `IterableExport` for rows that aren't models. - CSV values that would run as formulas are escaped; XLSX strings are never written as formulas. XLSX columns are sized to fit, and sheets are right-to-left in Dhivehi. +- `queue()` writes exports on the queue and returns an `Export` record (status, progress, file, user morph). `ProcessExport` streams the file to a configurable disk, saves progress, stops when cancelled, and notifies the user (mail by default) with a signed, expiring download link. +- `exports:prune` deletes expired exports and their files. +- Export events: `ExportStarted`, `ExportProgressed`, `ExportCompleted`, `ExportFailed`, `ExportCancelled`. diff --git a/composer.json b/composer.json index 4816871..da6df16 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,13 @@ ], "require": { "php": "^8.4", + "illuminate/console": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/filesystem": "^12.0 || ^13.0", + "illuminate/notifications": "^12.0 || ^13.0", + "illuminate/queue": "^12.0 || ^13.0", "illuminate/support": "^12.0 || ^13.0", + "laravel/serializable-closure": "^2.0", "openspout/openspout": "^5.0" }, "require-dev": { diff --git a/config/exports.php b/config/exports.php index 1830f94..d5f9859 100644 --- a/config/exports.php +++ b/config/exports.php @@ -1,5 +1,10 @@ 60, ], + /* + |-------------------------------------------------------------------------- + | The exports table and model + |-------------------------------------------------------------------------- + | + | user_key_type is the key type of the models that start exports (any + | model, through the `user` morph): integer, uuid, ulid or string. Set it + | before running the migration. + | + */ + + 'table' => 'exports', + + 'user_key_type' => 'integer', + + 'models' => [ + 'export' => Export::class, + ], + + /* + |-------------------------------------------------------------------------- + | Queued exports + |-------------------------------------------------------------------------- + | + | connection and queue null mean the app's defaults. Progress is saved + | (and broadcast) every progress_every rows; null means chunk_size. + | The job can be replaced with a subclass of ProcessExport. + | + */ + + 'queue' => [ + 'connection' => env('EXPORTS_QUEUE_CONNECTION'), + 'queue' => env('EXPORTS_QUEUE'), + 'timeout' => 3600, + 'progress_every' => null, + 'job' => ProcessExport::class, + ], + + /* + |-------------------------------------------------------------------------- + | Expiry + |-------------------------------------------------------------------------- + | + | Files can be downloaded for expire_after_days, then `exports:prune` + | deletes them. Signed links in notifications last signed_url_minutes, + | or until the file expires if that's sooner. + | + */ + + 'expire_after_days' => 7, + + 'signed_url_minutes' => 1440, + + /* + |-------------------------------------------------------------------------- + | Notifications + |-------------------------------------------------------------------------- + | + | The user who started a queued export is notified when it completes or + | fails, if their model uses Notifiable. Set a notification to null to + | turn it off. + | + */ + + 'notifications' => [ + 'enabled' => true, + 'channels' => ['mail'], + 'completed' => ExportCompletedNotification::class, + 'failed' => ExportFailedNotification::class, + ], + + /* + |-------------------------------------------------------------------------- + | Routes + |-------------------------------------------------------------------------- + | + | The name of the signed download route used in notifications. Route::exports() + | registers it as {name}.signed-download; change this if you change the name. + | + */ + + 'routes' => [ + 'signed_download' => 'exports.signed-download', + ], + ]; diff --git a/database/migrations/2026_11_04_000000_create_exports_table.php b/database/migrations/2026_11_04_000000_create_exports_table.php new file mode 100644 index 0000000..a451ccc --- /dev/null +++ b/database/migrations/2026_11_04_000000_create_exports_table.php @@ -0,0 +1,54 @@ +id(); + $table->uuid('uuid')->unique(); + + match (config('exports.user_key_type', 'integer')) { + 'uuid' => $table->nullableUuidMorphs('user'), + 'ulid' => $table->nullableUlidMorphs('user'), + 'string' => $this->nullableStringMorphs($table), + default => $table->nullableMorphs('user'), + }; + + $table->string('type')->nullable()->index(); + $table->string('export_class'); + $table->string('name'); + $table->string('format', 10); + $table->string('status', 20)->index(); + $table->unsignedBigInteger('total_rows')->nullable(); + $table->unsignedBigInteger('processed_rows')->default(0); + $table->string('disk')->nullable(); + $table->string('path')->nullable(); + $table->string('filename')->nullable(); + $table->unsignedBigInteger('size')->nullable(); + $table->text('error')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists(config('exports.table', 'exports')); + } + + protected function nullableStringMorphs(Blueprint $table): void + { + $table->string('user_type')->nullable(); + $table->string('user_id')->nullable(); + $table->index(['user_type', 'user_id']); + } +}; diff --git a/lang/dv.json b/lang/dv.json index 7a4a3fc..8034ae4 100644 --- a/lang/dv.json +++ b/lang/dv.json @@ -3,5 +3,7 @@ "No": "ނޫން", "ID": "އައިޑީ", "Name": "ނަން", - "Email": "އީމެއިލް" + "Email": "އީމެއިލް", + "Your :name export is ready": "ތިބާގެ :name އެކްސްޕޯޓް ތައްޔާރުވެއްޖެ", + "Your :name export failed": "ތިބާގެ :name އެކްސްޕޯޓް ނާކާމިޔާބުވެއްޖެ" } diff --git a/src/Commands/PruneExportsCommand.php b/src/Commands/PruneExportsCommand.php new file mode 100644 index 0000000..98e0979 --- /dev/null +++ b/src/Commands/PruneExportsCommand.php @@ -0,0 +1,43 @@ +expired(); + + if ($days = $this->option('days')) { + $query->orWhere('created_at', '<=', now()->subDays((int) $days)); + } + + $count = 0; + + $query->lazyById()->each(function (Export $export) use (&$count) { + $this->line("Deleting export {$export->uuid} ({$export->name}, {$export->status->value})..."); + + if (! $this->option('pretend')) { + $export->delete(); + } + + $count++; + }); + + $this->components->info($this->option('pretend') + ? "{$count} exports would be deleted." + : "Deleted {$count} exports."); + + return self::SUCCESS; + } +} diff --git a/src/Concerns/WritesFiles.php b/src/Concerns/WritesFiles.php index cdc44bd..d902bc6 100644 --- a/src/Concerns/WritesFiles.php +++ b/src/Concerns/WritesFiles.php @@ -2,12 +2,15 @@ namespace Javaabu\Exports\Concerns; -use Illuminate\Support\Facades\Storage; +use Illuminate\Database\Eloquent\Model; use Javaabu\Exports\Contracts\Exportable; +use Javaabu\Exports\Enums\ExportStatuses; use Javaabu\Exports\Enums\Formats; +use Javaabu\Exports\Exports; +use Javaabu\Exports\Jobs\ProcessExport; +use Javaabu\Exports\Models\Export; use Javaabu\Exports\Support\TemporaryFile; use Javaabu\Exports\Writers\SpreadsheetWriter; -use RuntimeException; use Symfony\Component\HttpFoundation\BinaryFileResponse; /** @@ -74,6 +77,48 @@ public function store(?string $path = null, ?string $disk = null, Formats|string return $path; } + /** + * Write the export on the queue. Returns the Export record that tracks it; the user + * (any model) is notified when the file is ready. + * + * The export object is serialized onto the queue: keep its state small (ids, filters, + * a query builder) rather than loaded collections. + */ + public function queue( + ?Model $user = null, + Formats|string|null $format = null, + ?string $filename = null, + ?string $disk = null, + ): Export { + $format = Formats::resolve($format, $filename); + + $export = Exports::newExportModel(); + + $export->forceFill([ + 'type' => Exports::typeOf($this), + 'export_class' => static::class, + 'name' => $this->label(), + 'format' => $format, + 'status' => ExportStatuses::PENDING, + 'disk' => $disk ?? config('exports.disk') ?? config('filesystems.default'), + 'filename' => $this->downloadName($filename, $format), + ]); + + if ($user) { + $export->user()->associate($user); + } + + $export->save(); + + $job_class = config('exports.queue.job', ProcessExport::class); + + dispatch(new $job_class($export, $this)) + ->onConnection(config('exports.queue.connection')) + ->onQueue(config('exports.queue.queue')); + + return $export; + } + /** * The file name for a download: the given name, with the format's extension added if missing, * or the export's file name and the current time @@ -93,18 +138,6 @@ public function downloadName(?string $filename, Formats $format): string protected function putOnDisk(string $local_path, string $path, ?string $disk): void { - $stream = fopen($local_path, 'r'); - - try { - $stored = Storage::disk($disk ?? config('exports.disk'))->writeStream($path, $stream); - } finally { - if (is_resource($stream)) { - fclose($stream); - } - } - - if ($stored === false) { - throw new RuntimeException("Could not write the export to {$path}."); - } + TemporaryFile::moveToDisk($local_path, $path, $disk ?? config('exports.disk')); } } diff --git a/src/Contracts/Exportable.php b/src/Contracts/Exportable.php index 86b836b..54c2107 100644 --- a/src/Contracts/Exportable.php +++ b/src/Contracts/Exportable.php @@ -30,4 +30,9 @@ public function totalRows(): ?int; * The file name without an extension, e.g. `users` */ public function filename(): string; + + /** + * The name shown in the exports list and notifications, e.g. "Users" + */ + public function label(): string; } diff --git a/src/Enums/ExportStatuses.php b/src/Enums/ExportStatuses.php new file mode 100644 index 0000000..abf7583 --- /dev/null +++ b/src/Enums/ExportStatuses.php @@ -0,0 +1,39 @@ + __('Pending'), + self::PROCESSING => __('Processing'), + self::COMPLETED => __('Completed'), + self::FAILED => __('Failed'), + self::CANCELLED => __('Cancelled'), + }; + } + + /** + * Whether the export has stopped, successfully or not + */ + public function isFinished(): bool + { + return in_array($this, [self::COMPLETED, self::FAILED, self::CANCELLED], true); + } + + /** + * Whether the export can still be cancelled + */ + public function isCancellable(): bool + { + return in_array($this, [self::PENDING, self::PROCESSING], true); + } +} diff --git a/src/Events/ExportCancelled.php b/src/Events/ExportCancelled.php new file mode 100644 index 0000000..cb261f1 --- /dev/null +++ b/src/Events/ExportCancelled.php @@ -0,0 +1,20 @@ +> */ + protected static array $registered = []; + + /** + * Let the exports API and UI start an export by name + * + * ```php + * Exports::register('users', UsersExport::class); + * ``` + * + * @param string|array> $type + * @param class-string|null $export_class + */ + public static function register(string|array $type, ?string $export_class = null): void + { + if (is_array($type)) { + foreach ($type as $name => $class) { + static::register($name, $class); + } + + return; + } + + if (! $export_class || ! is_a($export_class, Exportable::class, true)) { + throw new InvalidArgumentException("The export for [{$type}] must implement " . Exportable::class . '.'); + } + + static::$registered[$type] = $export_class; + } + + /** + * @return array> + */ + public static function registered(): array + { + return static::$registered; + } + + /** + * @return class-string|null + */ + public static function exportClass(string $type): ?string + { + return static::$registered[$type] ?? null; + } + + /** + * The registered name of an export class, if it has one + */ + public static function typeOf(Exportable|string $export): ?string + { + $class = is_string($export) ? $export : $export::class; + + $type = array_search($class, static::$registered, true); + + return $type === false ? null : $type; + } + + public static function forget(?string $type = null): void + { + if ($type === null) { + static::$registered = []; + + return; + } + + unset(static::$registered[$type]); + } + + /** + * The Export model class, from `config('exports.models.export')` + * + * @return class-string + */ + public static function exportModel(): string + { + return config('exports.models.export', Export::class); + } + + public static function newExportModel(): Export + { + $class = static::exportModel(); + + return new $class; + } + + /** + * Don't load the package's migrations; publish them with `--tag=exports-migrations` instead + */ + public static function ignoreMigrations(): void + { + static::$runs_migrations = false; + } + + /** + * The morph type and key of a user, for querying exports by owner + * + * @return array{0: string, 1: mixed} + */ + public static function ownerKey(Model $user): array + { + return [$user->getMorphClass(), $user->getKey()]; + } +} diff --git a/src/ExportsServiceProvider.php b/src/ExportsServiceProvider.php index 6a300a5..9cff33d 100644 --- a/src/ExportsServiceProvider.php +++ b/src/ExportsServiceProvider.php @@ -3,6 +3,7 @@ namespace Javaabu\Exports; use Illuminate\Support\ServiceProvider; +use Javaabu\Exports\Commands\PruneExportsCommand; class ExportsServiceProvider extends ServiceProvider { @@ -16,7 +17,19 @@ public function boot(): void $this->loadJsonTranslationsFrom(__DIR__ . '/../lang'); if ($this->app->runningInConsole()) { + $this->registerMigrations(); $this->registerPublishing(); + + $this->commands([ + PruneExportsCommand::class, + ]); + } + } + + protected function registerMigrations(): void + { + if (Exports::$runs_migrations) { + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); } } @@ -25,5 +38,9 @@ protected function registerPublishing(): void $this->publishes([ __DIR__ . '/../config/exports.php' => config_path('exports.php'), ], 'exports-config'); + + $this->publishes([ + __DIR__ . '/../database/migrations' => database_path('migrations'), + ], 'exports-migrations'); } } diff --git a/src/IterableExport.php b/src/IterableExport.php index 1393432..5ab1074 100644 --- a/src/IterableExport.php +++ b/src/IterableExport.php @@ -3,6 +3,7 @@ namespace Javaabu\Exports; use Closure; +use Illuminate\Support\Str; use Javaabu\Exports\Concerns\WritesFiles; use Javaabu\Exports\Contracts\Exportable; @@ -58,4 +59,9 @@ public function filename(): string { return $this->filename; } + + public function label(): string + { + return __(Str::headline($this->filename)); + } } diff --git a/src/Jobs/ProcessExport.php b/src/Jobs/ProcessExport.php new file mode 100644 index 0000000..bd55c19 --- /dev/null +++ b/src/Jobs/ProcessExport.php @@ -0,0 +1,183 @@ +timeout = (int) config('exports.queue.timeout', 3600); + } + + public function handle(SpreadsheetWriter $writer): void + { + $export = $this->export; + + $export->refresh(); + + if ($export->status !== ExportStatuses::PENDING) { + return; + } + + $export->markProcessing($this->exportable->totalRows()); + + event(new ExportStarted($export)); + + $local_path = TemporaryFile::path($export->format); + + try { + $rows = $writer->write( + $local_path, + $export->format, + $this->exportable->headings(), + $this->trackProgress($this->exportable->rows()), + ); + + if ($export->wasCancelled()) { + return; + } + + $path = $this->storagePath(); + $size = TemporaryFile::moveToDisk($local_path, $path, $export->disk); + + $export->markCompleted($export->disk, $path, $export->filename, $rows, $size); + } catch (ExportCancelledException) { + return; + } finally { + TemporaryFile::delete($local_path); + } + + event(new ExportCompleted($export)); + + $this->notifyUser('completed'); + } + + public function failed(?Throwable $exception): void + { + $export = $this->export->fresh(); + + if (! $export) { + return; + } + + if ($export->status->isFinished()) { + return; + } + + $this->export = $export; + + $export->markFailed($exception ?? __('The export failed.')); + + event(new ExportFailed($export)); + + $this->notifyUser('failed'); + } + + /** + * Pass the rows through, saving progress and checking for cancellation as they go + * + * @param iterable> $rows + * @return Generator> + */ + protected function trackProgress(iterable $rows): Generator + { + $every = $this->progressEvery(); + $count = 0; + + foreach ($rows as $row) { + yield $row; + + $count++; + + if ($count % $every !== 0) { + continue; + } + + if ($this->export->wasCancelled()) { + throw new ExportCancelledException; + } + + $this->export->recordProgress($count); + + event(new ExportProgressed($this->export)); + } + } + + protected function progressEvery(): int + { + $every = config('exports.queue.progress_every') ?: config('exports.chunk_size', 1000); + + return max((int) $every, 1); + } + + /** + * `exports/{uuid}/{filename}`, so names never collide + */ + protected function storagePath(): string + { + $directory = trim((string) config('exports.directory', 'exports'), '/'); + + return ltrim("{$directory}/{$this->export->uuid}/{$this->export->filename}", '/'); + } + + protected function notifyUser(string $type): void + { + if (! config('exports.notifications.enabled', true)) { + return; + } + + $notification = config("exports.notifications.{$type}"); + + if (! $notification) { + return; + } + + $user = $this->export->user; + + if (! $user || ! method_exists($user, 'notify')) { + return; + } + + $user->notify(new $notification($this->export)); + } +} diff --git a/src/ModelExport.php b/src/ModelExport.php index 5e9509c..1d2d057 100644 --- a/src/ModelExport.php +++ b/src/ModelExport.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Queue\SerializesAndRestoresModelIdentifiers; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Schema; @@ -19,9 +20,11 @@ use Javaabu\Exports\Concerns\WritesFiles; use Javaabu\Exports\Contracts\Exportable; use Javaabu\Exports\Contracts\HasExportHeadings; +use Javaabu\Exports\Support\SerializableQuery; use JsonSerializable; use ReflectionMethod; use ReflectionNamedType; +use ReflectionObject; use Throwable; use UnitEnum; @@ -34,6 +37,7 @@ */ abstract class ModelExport implements Exportable { + use SerializesAndRestoresModelIdentifiers; use WritesFiles; /** Attributes tried, in order, to show a related model */ @@ -106,8 +110,12 @@ public function query(): Builder|Relation { $query = $this->query ?? $this->modelClass()::query(); - if ($relations = $this->eagerLoads()) { - $query->with($relations); + $builder = $query instanceof Relation ? $query->getQuery() : $query; + + $missing = array_diff($this->eagerLoads(), array_keys($builder->getEagerLoads())); + + if ($missing) { + $query->with(array_values($missing)); } return $query; @@ -189,6 +197,70 @@ public function filename(): string return Str::of(class_basename($this->modelClass()))->plural()->kebab()->toString(); } + /** + * The name shown in the exports list, e.g. "Users" + */ + public function label(): string + { + return __(Str::of(class_basename($this->modelClass()))->plural()->headline()->toString()); + } + + /** + * Whether the user may run this export. Defaults to the `viewAny` ability on the model. + */ + public function authorize(?Model $user): bool + { + return $user !== null && $user->can('viewAny', $this->modelClass()); + } + + /** + * Serialize for the queue: models become identifiers, a query becomes a SerializableQuery + * + * @return array + */ + public function __serialize(): array + { + $data = []; + + foreach ((new ReflectionObject($this))->getProperties() as $property) { + if ($property->isStatic() || ! $property->isInitialized($this)) { + continue; + } + + $name = $property->getName(); + $value = $property->getValue($this); + + $data[$name] = match (true) { + $name === 'query' && $value !== null => SerializableQuery::from($value), + $name === 'resolved_columns' => null, + $name === 'relation_names' => [], + default => $this->getSerializedPropertyValue($value), + }; + } + + return $data; + } + + /** + * @param array $data + */ + public function __unserialize(array $data): void + { + $reflection = new ReflectionObject($this); + + foreach ($data as $name => $value) { + if (! $reflection->hasProperty($name)) { + continue; + } + + $value = $value instanceof SerializableQuery + ? $value->toBuilder() + : $this->getRestoredPropertyValue($value); + + $reflection->getProperty($name)->setValue($this, $value); + } + } + /** * @return array */ diff --git a/src/Models/Export.php b/src/Models/Export.php new file mode 100644 index 0000000..4407359 --- /dev/null +++ b/src/Models/Export.php @@ -0,0 +1,309 @@ + 0, + ]; + + protected function casts(): array + { + return [ + 'status' => ExportStatuses::class, + 'format' => Formats::class, + 'total_rows' => 'integer', + 'processed_rows' => 'integer', + 'size' => 'integer', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'failed_at' => 'datetime', + 'cancelled_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (Export $export) { + $export->uuid ??= (string) Str::uuid(); + $export->status ??= ExportStatuses::PENDING; + }); + + static::deleting(function (Export $export) { + $export->deleteFile(); + }); + } + + public function getTable(): string + { + return config('exports.table', parent::getTable()); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function user(): MorphTo + { + return $this->morphTo(); + } + + /** + * Exports started by the user + */ + public function scopeOwnedBy(Builder $query, Model $user): void + { + $query->where('user_type', $user->getMorphClass()) + ->where('user_id', $user->getKey()); + } + + /** + * Exports whose file has expired, or that never finished and are older than the + * expiry period + */ + public function scopeExpired(Builder $query, ?DateTimeInterface $now = null): void + { + $now ??= now(); + + $query->where(function (Builder $query) use ($now) { + $query->where('expires_at', '<=', $now) + ->orWhere(function (Builder $query) use ($now) { + $query->whereNull('expires_at') + ->where('created_at', '<=', Carbon::instance($now)->subDays(static::expiresAfterDays())); + }); + }); + } + + public function scopeStatus(Builder $query, ExportStatuses|string $status): void + { + $query->where('status', $status instanceof ExportStatuses ? $status->value : $status); + } + + public function isOwnedBy(?Model $user): bool + { + if (! $user) { + return false; + } + + return $this->user_type === $user->getMorphClass() + && (string) $this->user_id === (string) $user->getKey(); + } + + /** + * Progress from 0 to 100, or null while the number of rows is unknown + */ + public function progress(): ?int + { + if ($this->status === ExportStatuses::COMPLETED) { + return 100; + } + + if (! $this->total_rows) { + return $this->total_rows === 0 ? 100 : null; + } + + return (int) min(floor($this->processed_rows / $this->total_rows * 100), 100); + } + + public function hasExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + /** + * Whether the file can be downloaded: completed, stored and not expired + */ + public function isDownloadable(): bool + { + return $this->status === ExportStatuses::COMPLETED + && $this->path + && ! $this->hasExpired(); + } + + public function isCancellable(): bool + { + return $this->status->isCancellable(); + } + + /** + * Whether the export was cancelled, checked against the database + */ + public function wasCancelled(): bool + { + $status = static::query()->whereKey($this->getKey())->toBase()->value('status'); + + return $status === ExportStatuses::CANCELLED->value; + } + + /** + * Stop a pending or processing export. The job stops at the next chunk. + */ + public function cancel(): bool + { + if (! $this->isCancellable()) { + return false; + } + + $this->forceFill([ + 'status' => ExportStatuses::CANCELLED, + 'cancelled_at' => now(), + ])->save(); + + event(new ExportCancelled($this)); + + return true; + } + + public function markProcessing(?int $total_rows): void + { + $this->forceFill([ + 'status' => ExportStatuses::PROCESSING, + 'total_rows' => $total_rows, + 'processed_rows' => 0, + 'started_at' => now(), + 'error' => null, + ])->save(); + } + + public function recordProgress(int $processed_rows): void + { + $this->forceFill(['processed_rows' => $processed_rows])->save(); + } + + public function markCompleted(string $disk, string $path, string $filename, int $rows, ?int $size): void + { + $this->forceFill([ + 'status' => ExportStatuses::COMPLETED, + 'disk' => $disk, + 'path' => $path, + 'filename' => $filename, + 'processed_rows' => $rows, + 'total_rows' => $rows, + 'size' => $size, + 'completed_at' => now(), + 'expires_at' => now()->addDays(static::expiresAfterDays()), + ])->save(); + } + + public function markFailed(Throwable|string $error): void + { + $message = $error instanceof Throwable ? $error->getMessage() : $error; + + $this->forceFill([ + 'status' => ExportStatuses::FAILED, + 'error' => Str::limit($message, 1000), + 'failed_at' => now(), + ])->save(); + } + + /** + * Stream the file to the browser + */ + public function download(): StreamedResponse + { + return Storage::disk($this->disk)->download($this->path, $this->filename, [ + 'Content-Type' => $this->format->mimeType(), + ]); + } + + public function fileExists(): bool + { + return $this->path && Storage::disk($this->disk)->exists($this->path); + } + + public function deleteFile(): void + { + if (! $this->path) { + return; + } + + Storage::disk($this->disk)->delete($this->path); + } + + /** + * A signed link to download the file without logging in, for notifications. + * It expires after `exports.signed_url_minutes`, or when the file expires if sooner. + * Null when the export isn't downloadable or the signed route isn't registered. + */ + public function temporaryDownloadUrl(?DateTimeInterface $expires_at = null): ?string + { + if (! $this->isDownloadable()) { + return null; + } + + $route = config('exports.routes.signed_download', 'exports.signed-download'); + + if (! Route::has($route)) { + return null; + } + + $expires_at ??= now()->addMinutes((int) config('exports.signed_url_minutes', 1440)); + + if ($this->expires_at && $this->expires_at->lessThan($expires_at)) { + $expires_at = $this->expires_at; + } + + return URL::temporarySignedRoute($route, $expires_at, ['export' => $this->uuid]); + } + + /** + * The private broadcast channel for this export's progress + */ + public function broadcastChannel(): string + { + return config('exports.broadcasting.channel', 'exports') . ".{$this->uuid}"; + } + + public static function expiresAfterDays(): int + { + return max((int) config('exports.expire_after_days', 7), 1); + } +} diff --git a/src/Notifications/ExportCompletedNotification.php b/src/Notifications/ExportCompletedNotification.php new file mode 100644 index 0000000..6b8a4ef --- /dev/null +++ b/src/Notifications/ExportCompletedNotification.php @@ -0,0 +1,62 @@ + + */ + public function via(object $notifiable): array + { + return config('exports.notifications.channels', ['mail']); + } + + public function toMail(object $notifiable): MailMessage + { + $message = (new MailMessage) + ->subject(__('Your :name export is ready', ['name' => $this->export->name])) + ->line(__('Your :name export with :count rows is ready to download.', [ + 'name' => $this->export->name, + 'count' => number_format((int) $this->export->total_rows), + ])); + + if ($url = $this->export->temporaryDownloadUrl()) { + $message->action(__('Download'), $url); + } + + if ($this->export->expires_at) { + $message->line(__('The file will be deleted on :date.', [ + 'date' => $this->export->expires_at->toFormattedDayDateString(), + ])); + } + + return $message; + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'export' => $this->export->uuid, + 'name' => $this->export->name, + 'status' => $this->export->status->value, + 'rows' => $this->export->total_rows, + 'download_url' => $this->export->temporaryDownloadUrl(), + 'message' => __('Your :name export is ready', ['name' => $this->export->name]), + ]; + } +} diff --git a/src/Notifications/ExportFailedNotification.php b/src/Notifications/ExportFailedNotification.php new file mode 100644 index 0000000..33cdf64 --- /dev/null +++ b/src/Notifications/ExportFailedNotification.php @@ -0,0 +1,48 @@ + + */ + public function via(object $notifiable): array + { + return config('exports.notifications.channels', ['mail']); + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->error() + ->subject(__('Your :name export failed', ['name' => $this->export->name])) + ->line(__('Your :name export could not be completed. Please try again, or contact us if it keeps failing.', [ + 'name' => $this->export->name, + ])); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'export' => $this->export->uuid, + 'name' => $this->export->name, + 'status' => $this->export->status->value, + 'message' => __('Your :name export failed', ['name' => $this->export->name]), + ]; + } +} diff --git a/src/Support/SerializableQuery.php b/src/Support/SerializableQuery.php new file mode 100644 index 0000000..04c7fcb --- /dev/null +++ b/src/Support/SerializableQuery.php @@ -0,0 +1,87 @@ + $model + * @param array $bindings + * @param array> $orders + * @param array $order_bindings + * @param array $eager_loads + */ + public function __construct( + public string $model, + public ?string $connection, + public string $sql, + public array $bindings, + public array $orders, + public array $order_bindings, + public array $eager_loads, + ) {} + + public static function from(Builder|Relation $query): self + { + $builder = $query instanceof Relation ? $query->getQuery() : $query; + $model = $builder->getModel(); + $base = $builder->toBase(); + + $eager_loads = array_map( + fn (Closure $constraint) => new SerializableClosure($constraint), + $builder->getEagerLoads(), + ); + + return new self( + $model::class, + $model->getConnectionName(), + $base->toSql(), + $base->getBindings(), + $base->orders ?? [], + $base->getRawBindings()['order'] ?? [], + $eager_loads, + ); + } + + public function toBuilder(): Builder + { + /** @var Model $model */ + $model = new $this->model; + $model->setConnection($this->connection); + + $subquery = DB::connection($this->connection)->query()->fromRaw("({$this->sql}) as {$this->wrappedTable($model)}", $this->bindings); + + $query = $model->newQueryWithoutScopes()->setQuery($subquery); + + $base = $query->getQuery(); + $base->orders = $this->orders ?: null; + $base->setBindings($this->order_bindings, 'order'); + + $query->setEagerLoads(array_map( + fn (SerializableClosure $constraint) => $constraint->getClosure(), + $this->eager_loads, + )); + + return $query; + } + + protected function wrappedTable(Model $model): string + { + return $model->getConnection()->getQueryGrammar()->wrapTable($model->getTable()); + } +} diff --git a/src/Support/TemporaryFile.php b/src/Support/TemporaryFile.php index 75da2c3..fe8c409 100644 --- a/src/Support/TemporaryFile.php +++ b/src/Support/TemporaryFile.php @@ -2,8 +2,10 @@ namespace Javaabu\Exports\Support; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Javaabu\Exports\Enums\Formats; +use RuntimeException; /** * Paths for files written locally before they're sent or moved to a disk @@ -21,6 +23,31 @@ public static function path(Formats $format): string return $directory . DIRECTORY_SEPARATOR . 'export-' . Str::random(32) . '.' . $format->extension(); } + /** + * Stream a local file to a disk + * + * @return int|null the size of the file in bytes + */ + public static function moveToDisk(string $local_path, string $path, ?string $disk): ?int + { + $size = filesize($local_path) ?: null; + $stream = fopen($local_path, 'r'); + + try { + $stored = Storage::disk($disk)->writeStream($path, $stream); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + if ($stored === false) { + throw new RuntimeException("Could not write the export to {$path}."); + } + + return $size; + } + public static function delete(string $path): void { if (is_file($path)) { diff --git a/tests/Feature/ExportModelTest.php b/tests/Feature/ExportModelTest.php new file mode 100644 index 0000000..114c694 --- /dev/null +++ b/tests/Feature/ExportModelTest.php @@ -0,0 +1,177 @@ +set('exports.disk', 'exports'); + } + + #[Test] + public function it_reports_progress(): void + { + $export = $this->makeExport(['status' => ExportStatuses::PROCESSING, 'total_rows' => 200, 'processed_rows' => 50]); + + $this->assertEquals(25, $export->progress()); + + $export->total_rows = null; + $this->assertNull($export->progress()); + + $export->total_rows = 0; + $this->assertEquals(100, $export->progress()); + + $export->status = ExportStatuses::COMPLETED; + $this->assertEquals(100, $export->progress()); + } + + #[Test] + public function it_finds_exports_by_owner(): void + { + $ahmed = User::factory()->create(); + $aisha = User::factory()->create(); + + $ahmeds = $this->makeExport(user: $ahmed); + $this->makeExport(user: $aisha); + + $this->assertEquals([$ahmeds->id], Export::query()->ownedBy($ahmed)->pluck('id')->all()); + $this->assertTrue($ahmeds->isOwnedBy($ahmed)); + $this->assertFalse($ahmeds->isOwnedBy($aisha)); + $this->assertFalse($ahmeds->isOwnedBy(null)); + $this->assertTrue($ahmeds->user->is($ahmed)); + } + + #[Test] + public function it_knows_when_it_can_be_downloaded(): void + { + $export = $this->completedExport(); + + $this->assertTrue($export->isDownloadable()); + $this->assertTrue($export->fileExists()); + + $this->travel(8)->days(); + + $this->assertTrue($export->hasExpired()); + $this->assertFalse($export->isDownloadable()); + $this->assertNull($export->temporaryDownloadUrl()); + } + + #[Test] + public function it_has_no_signed_link_without_the_signed_route(): void + { + $this->assertNull($this->completedExport()->temporaryDownloadUrl()); + } + + #[Test] + public function it_streams_its_file(): void + { + $response = $this->completedExport()->download(); + + $this->assertEquals('text/csv', $response->headers->get('Content-Type')); + $this->assertStringContainsString(rawurlencode('ބޭނުންތެރިން') . '.csv', $response->headers->get('Content-Disposition')); + } + + #[Test] + public function it_deletes_its_file_with_it(): void + { + $export = $this->completedExport(); + $path = $export->path; + + $export->delete(); + + Storage::disk('exports')->assertMissing($path); + } + + #[Test] + public function it_uses_the_configured_model_and_table(): void + { + $this->assertEquals(Export::class, Exports::exportModel()); + $this->assertEquals('exports', (new Export)->getTable()); + $this->assertEquals('uuid', (new Export)->getRouteKeyName()); + $this->assertEquals('Pending', ExportStatuses::PENDING->label()); + } + + #[Test] + public function it_prunes_expired_exports(): void + { + $expired = $this->completedExport(); + $expired->forceFill(['expires_at' => now()->subMinute()])->save(); + + $stuck = $this->makeExport(['status' => ExportStatuses::PROCESSING]); + $stuck->forceFill(['created_at' => now()->subDays(8)])->save(); + + $recent = $this->completedExport(); + $old_but_valid = $this->completedExport(); + $old_but_valid->forceFill(['created_at' => now()->subDays(3)])->save(); + + $this->artisan('exports:prune --pretend') + ->expectsOutputToContain('2 exports would be deleted.') + ->assertSuccessful(); + + $this->assertEquals(4, Export::count()); + + $this->artisan('exports:prune') + ->expectsOutputToContain('Deleted 2 exports.') + ->assertSuccessful(); + + $this->assertEquals([$recent->id, $old_but_valid->id], Export::pluck('id')->all()); + Storage::disk('exports')->assertMissing($expired->path); + + $this->artisan('exports:prune --days=2') + ->expectsOutputToContain('Deleted 1 exports.') + ->assertSuccessful(); + + $this->assertEquals([$recent->id], Export::pluck('id')->all()); + } + + /** + * @param array $attributes + */ + protected function makeExport(array $attributes = [], ?User $user = null): Export + { + $export = new Export; + + $export->forceFill(array_merge([ + 'export_class' => UsersExport::class, + 'name' => 'Users', + 'format' => Formats::CSV, + 'disk' => 'exports', + 'filename' => 'ބޭނުންތެރިން.csv', + ], $attributes)); + + $export->user()->associate($user ?? User::factory()->create()); + $export->save(); + + return $export; + } + + protected function completedExport(): Export + { + $export = $this->makeExport(); + + $path = "exports/{$export->uuid}/ބޭނުންތެރިން.csv"; + Storage::disk('exports')->put($path, 'a,b'); + + $export->markCompleted('exports', $path, 'ބޭނުންތެރިން.csv', 1, 3); + + return $export; + } +} diff --git a/tests/Feature/QueuedExportsTest.php b/tests/Feature/QueuedExportsTest.php new file mode 100644 index 0000000..93d4f38 --- /dev/null +++ b/tests/Feature/QueuedExportsTest.php @@ -0,0 +1,299 @@ +set('exports.disk', 'exports'); + } + + protected function tearDown(): void + { + Exports::forget(); + + parent::tearDown(); + } + + #[Test] + public function it_creates_a_pending_export_and_dispatches_the_job(): void + { + Queue::fake(); + + Exports::register('users', UsersExport::class); + + $user = User::factory()->create(); + + $export = UsersExport::make()->queue($user, Formats::CSV, 'ބޭނުންތެރިން'); + + $this->assertDatabaseHas('exports', [ + 'id' => $export->id, + 'type' => 'users', + 'export_class' => UsersExport::class, + 'name' => 'Users', + 'format' => 'csv', + 'status' => 'pending', + 'disk' => 'exports', + 'filename' => 'ބޭނުންތެރިން.csv', + 'user_type' => 'user', + 'user_id' => $user->id, + ]); + + $this->assertTrue($export->isOwnedBy($user)); + $this->assertNotEmpty($export->uuid); + + Queue::assertPushed(ProcessExport::class, fn (ProcessExport $job) => $job->export->is($export)); + } + + #[Test] + public function it_pushes_the_job_to_the_configured_queue(): void + { + Queue::fake(); + + config()->set('exports.queue.connection', 'redis'); + config()->set('exports.queue.queue', 'exports'); + + UsersExport::make()->queue(); + + Queue::assertPushedOn('exports', ProcessExport::class, fn (ProcessExport $job) => $job->connection === 'redis'); + } + + #[Test] + public function it_writes_the_file_to_the_disk_and_notifies_the_user(): void + { + Notification::fake(); + Event::fake([ExportStarted::class, ExportProgressed::class, ExportCompleted::class]); + + config()->set('exports.queue.progress_every', 10); + + $user = User::factory()->create(['name' => 'އަހުމަދު']); + $this->seedUsers(24); + + $export = UsersExport::make()->queue($user, 'xlsx', 'users')->fresh(); + + $this->assertEquals(ExportStatuses::COMPLETED, $export->status); + $this->assertEquals(25, $export->total_rows); + $this->assertEquals(25, $export->processed_rows); + $this->assertEquals(100, $export->progress()); + $this->assertEquals("exports/{$export->uuid}/users.xlsx", $export->path); + $this->assertGreaterThan(0, $export->size); + $this->assertNotNull($export->started_at); + $this->assertNotNull($export->completed_at); + $this->assertEquals(now()->addDays(7)->toDateString(), $export->expires_at->toDateString()); + + Storage::disk('exports')->assertExists($export->path); + + $rows = $this->readSpreadsheet(Storage::disk('exports')->path($export->path)); + $this->assertCount(26, $rows); + $this->assertEquals('އަހުމަދު', $rows[1][2]); + + Event::assertDispatchedTimes(ExportStarted::class, 1); + Event::assertDispatchedTimes(ExportProgressed::class, 2); + Event::assertDispatchedTimes(ExportCompleted::class, 1); + + Notification::assertSentTo($user, ExportCompletedNotification::class); + } + + #[Test] + public function it_runs_a_serialized_query_on_the_queue(): void + { + $admin = User::factory()->create(['name' => 'ބ', 'is_admin' => true]); + $admin->organizations()->sync([ + Organization::factory()->create(['name' => 'ޖާވާބު'])->id, + Organization::factory()->create(['name' => 'ސިފައިން'])->id, + ]); + + User::factory()->create(['name' => 'އ', 'is_admin' => true]); + User::factory()->create(['name' => 'ދ', 'is_admin' => false]); + + $query = User::query() + ->where('is_admin', true) + ->orderBy('name', 'desc') + ->with(['organizations' => fn ($query) => $query->where('name', 'ޖާވާބު')]); + + $export = UsersExport::make($query); + $restored = unserialize(serialize($export)); + + $this->assertEquals(iterator_to_array($export->rows(), false), iterator_to_array($restored->rows(), false)); + + $names = array_column(iterator_to_array($restored->rows(), false), 2); + $this->assertEquals(['އ', 'ބ'], $names); + $this->assertEquals('ޖާވާބު', iterator_to_array($restored->rows(), false)[1][11]); + $this->assertEquals(2, $restored->totalRows()); + } + + #[Test] + public function it_restores_models_held_by_an_export(): void + { + $user = User::factory()->create(['name' => 'އައިޝާ']); + + $export = new OwnedUsersExport($user); + + $serialized = serialize($export); + + $this->assertStringNotContainsString('އައިޝާ', $serialized); + $this->assertTrue(unserialize($serialized)->owner->is($user)); + } + + #[Test] + public function it_marks_the_export_failed_and_notifies_the_user(): void + { + Notification::fake(); + Event::fake([ExportFailed::class]); + + $user = User::factory()->create(); + + try { + FailingExport::make()->queue($user); + + $this->fail('The sync queue should rethrow the exception.'); + } catch (RuntimeException $exception) { + $this->assertEquals('The database went away', $exception->getMessage()); + } + + $export = Export::first(); + + $this->assertEquals(ExportStatuses::FAILED, $export->status); + $this->assertEquals('The database went away', $export->error); + $this->assertNotNull($export->failed_at); + $this->assertNull($export->path); + $this->assertEmpty(Storage::disk('exports')->allFiles()); + + Event::assertDispatched(ExportFailed::class); + Notification::assertSentTo($user, ExportFailedNotification::class); + } + + #[Test] + public function it_stops_when_the_export_is_cancelled(): void + { + Notification::fake(); + + config()->set('exports.queue.progress_every', 5); + + $this->seedUsers(30); + + Event::listen(ExportProgressed::class, function (ExportProgressed $event) { + Export::query()->whereKey($event->export->id)->first()->cancel(); + }); + + $export = UsersExport::make()->queue(User::first())->fresh(); + + $this->assertEquals(ExportStatuses::CANCELLED, $export->status); + $this->assertEquals(5, $export->processed_rows); + $this->assertNotNull($export->cancelled_at); + $this->assertNull($export->path); + $this->assertEmpty(Storage::disk('exports')->allFiles()); + + Notification::assertNothingSent(); + } + + #[Test] + public function it_skips_exports_cancelled_before_the_job_runs(): void + { + Queue::fake(); + Event::fake([ExportCancelled::class, ExportStarted::class]); + + $export = UsersExport::make()->queue(); + + $this->assertTrue($export->cancel()); + $this->assertFalse($export->cancel()); + + (new ProcessExport($export, UsersExport::make()))->handle(app(SpreadsheetWriter::class)); + + $this->assertEquals(ExportStatuses::CANCELLED, $export->fresh()->status); + + Event::assertDispatchedTimes(ExportCancelled::class, 1); + Event::assertNotDispatched(ExportStarted::class); + } + + #[Test] + public function it_does_not_notify_when_notifications_are_off(): void + { + Notification::fake(); + + config()->set('exports.notifications.enabled', false); + + UsersExport::make()->queue(User::factory()->create()); + + Notification::assertNothingSent(); + } + + #[Test] + public function the_completed_notification_has_a_signed_download_link(): void + { + Route::get('exports/{export}/signed', fn () => 'ok')->name('exports.signed-download'); + Route::getRoutes()->refreshNameLookups(); + + $user = User::factory()->create(); + + $export = UsersExport::make()->queue($user, 'csv', 'users')->fresh(); + + $mail = (new ExportCompletedNotification($export))->toMail($user); + + $this->assertEquals('Your Users export is ready', $mail->subject); + $this->assertStringContainsString("exports/{$export->uuid}/signed?expires=", $mail->actionUrl); + $this->assertStringContainsString('signature=', $mail->actionUrl); + + $this->assertEquals($export->uuid, (new ExportCompletedNotification($export))->toArray($user)['export']); + } + + #[Test] + public function the_notifications_are_translated(): void + { + app()->setLocale('dv'); + app('translator')->load('*', '*', 'dv'); + app('translator')->addLines(['*.Users' => 'ޔޫޒަރުން'], 'dv'); + + $user = User::factory()->create(); + + $export = UsersExport::make()->queue($user, 'csv')->fresh(); + + $mail = (new ExportCompletedNotification($export))->toMail($user); + + $this->assertEquals('ތިބާގެ ޔޫޒަރުން އެކްސްޕޯޓް ތައްޔާރުވެއްޖެ', $mail->subject); + $this->assertNull($mail->actionUrl); + + $export->markFailed('x'); + + $failed = (new ExportFailedNotification($export))->toMail($user); + + $this->assertEquals('ތިބާގެ ޔޫޒަރުން އެކްސްޕޯޓް ނާކާމިޔާބުވެއްޖެ', $failed->subject); + } +} diff --git a/tests/TestSupport/Exports/FailingExport.php b/tests/TestSupport/Exports/FailingExport.php new file mode 100644 index 0000000..cb01049 --- /dev/null +++ b/tests/TestSupport/Exports/FailingExport.php @@ -0,0 +1,15 @@ +loadMigrationsFrom([ __DIR__ . '/../database', ]); - } - /** - * Register the application services. - */ - public function register() {} + Relation::morphMap([ + 'user' => User::class, + 'role' => Role::class, + 'organization' => Organization::class, + ]); + } } From 6e3283ee41e783d5007321129646ccb9447577f5 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 13:35:57 +0500 Subject: [PATCH 04/13] Add exports routes, an overridable controller, the policy and permissions Route::exports() registers index, store, show, download, cancel and destroy under the app's prefix, name, guard and middleware, plus a signed download route for notification links. ExportsController answers JSON (ExportResource with progress, abilities, links and broadcast info) or renders pages through ViewRenderer, and is replaceable from config, the macro or the container. Permissions view_exports, view_others_exports, delete_exports and delete_others_exports are registered with javaabu/permissions, with ExportPolicy as the default policy (overridable from permissions.policies or Gate::policy()). Users see their own exports; *_others_* see everyone's. Tested with two guards and two user models. Closes #11 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + composer.json | 9 + config/exports.php | 35 ++ src/ExportsServiceProvider.php | 39 ++ src/Http/Controllers/ExportsController.php | 325 ++++++++++++++++ src/Http/Requests/ExportRequest.php | 43 +++ src/Http/Resources/ExportResource.php | 74 ++++ src/ModelExport.php | 9 + src/Policies/ExportPolicy.php | 85 +++++ src/Routing/ExportRoutes.php | 82 ++++ src/Support/Broadcasting.php | 38 ++ src/Views/ViewRenderer.php | 64 ++++ tests/Feature/StreamingTest.php | 2 +- tests/Http/ControllerFromConfigTest.php | 28 ++ tests/Http/ExportsControllerTest.php | 357 ++++++++++++++++++ tests/Http/InteractsWithExports.php | 80 ++++ tests/Http/OverridingControllersTest.php | 50 +++ tests/TestCase.php | 39 +- tests/TestSupport/Factories/AdminFactory.php | 19 + .../Http/CustomExportsController.php | 17 + tests/TestSupport/Models/Admin.php | 25 ++ tests/TestSupport/Models/Role.php | 2 + tests/TestSupport/Models/User.php | 4 + tests/TestSupport/Policies/DenyAllPolicy.php | 11 + tests/TestSupport/Policies/UserPolicy.php | 10 + .../Providers/TestServiceProvider.php | 2 + .../2024_01_05_223412_create_roles_table.php | 4 +- .../2024_02_05_223412_create_users_table.php | 2 +- .../2024_06_05_223412_create_admins_table.php | 23 ++ 29 files changed, 1473 insertions(+), 8 deletions(-) create mode 100644 src/Http/Controllers/ExportsController.php create mode 100644 src/Http/Requests/ExportRequest.php create mode 100644 src/Http/Resources/ExportResource.php create mode 100644 src/Policies/ExportPolicy.php create mode 100644 src/Routing/ExportRoutes.php create mode 100644 src/Support/Broadcasting.php create mode 100644 src/Views/ViewRenderer.php create mode 100644 tests/Http/ControllerFromConfigTest.php create mode 100644 tests/Http/ExportsControllerTest.php create mode 100644 tests/Http/InteractsWithExports.php create mode 100644 tests/Http/OverridingControllersTest.php create mode 100644 tests/TestSupport/Factories/AdminFactory.php create mode 100644 tests/TestSupport/Http/CustomExportsController.php create mode 100644 tests/TestSupport/Models/Admin.php create mode 100644 tests/TestSupport/Policies/DenyAllPolicy.php create mode 100644 tests/TestSupport/Policies/UserPolicy.php create mode 100644 tests/TestSupport/database/2024_06_05_223412_create_admins_table.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b4e90..6d27d5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,6 @@ All notable changes will be documented in this file. - `queue()` writes exports on the queue and returns an `Export` record (status, progress, file, user morph). `ProcessExport` streams the file to a configurable disk, saves progress, stops when cancelled, and notifies the user (mail by default) with a signed, expiring download link. - `exports:prune` deletes expired exports and their files. - Export events: `ExportStarted`, `ExportProgressed`, `ExportCompleted`, `ExportFailed`, `ExportCancelled`. +- `Route::exports()` registers the exports routes (index, store, show, download, cancel, destroy, signed download) with the app's prefix, name, guard and middleware. `ExportsController` answers JSON or renders pages, and can be replaced from config, the macro or the container. +- `ExportPolicy` and the permissions `view_exports`, `view_others_exports`, `delete_exports`, `delete_others_exports`, registered with javaabu/permissions. Exports belong to any user model through a morph; nothing assumes the `web` guard. +- `Exports::register('users', UsersExport::class)` lets the API start an export by name. diff --git a/composer.json b/composer.json index da6df16..b13ffe6 100644 --- a/composer.json +++ b/composer.json @@ -18,14 +18,23 @@ "role": "Developer" } ], + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/Javaabu/permissions" + } + ], "require": { "php": "^8.4", "illuminate/console": "^12.0 || ^13.0", "illuminate/database": "^12.0 || ^13.0", "illuminate/filesystem": "^12.0 || ^13.0", + "illuminate/http": "^12.0 || ^13.0", "illuminate/notifications": "^12.0 || ^13.0", "illuminate/queue": "^12.0 || ^13.0", + "illuminate/routing": "^12.0 || ^13.0", "illuminate/support": "^12.0 || ^13.0", + "javaabu/permissions": "^3.0@dev", "laravel/serializable-closure": "^2.0", "openspout/openspout": "^5.0" }, diff --git a/config/exports.php b/config/exports.php index d5f9859..d7e47cd 100644 --- a/config/exports.php +++ b/config/exports.php @@ -1,9 +1,11 @@ 'exports.signed-download', ], + /* + |-------------------------------------------------------------------------- + | Views + |-------------------------------------------------------------------------- + | + | The pages the exports routes render: blade (javaabu/forms components) or + | inertia (React pages shipped through javaabu/manifest). Requests that + | expect JSON always get JSON. + | + */ + + 'views' => env('EXPORTS_VIEWS', 'blade'), + + /* + |-------------------------------------------------------------------------- + | Controllers and authorization + |-------------------------------------------------------------------------- + | + | Replace the controller with a subclass, or pass `controller` to + | Route::exports(). The policy here is the package default; apps can also + | override it with config('permissions.policies') or Gate::policy(). + | Turn off register_permissions to register your own. + | + */ + + 'controllers' => [ + 'exports' => ExportsController::class, + ], + + 'policy' => ExportPolicy::class, + + 'register_permissions' => true, + ]; diff --git a/src/ExportsServiceProvider.php b/src/ExportsServiceProvider.php index 9cff33d..0c4c2ab 100644 --- a/src/ExportsServiceProvider.php +++ b/src/ExportsServiceProvider.php @@ -2,8 +2,12 @@ namespace Javaabu\Exports; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Javaabu\Exports\Commands\PruneExportsCommand; +use Javaabu\Exports\Policies\ExportPolicy; +use Javaabu\Exports\Routing\ExportRoutes; +use Javaabu\Permissions\Permissions; class ExportsServiceProvider extends ServiceProvider { @@ -15,6 +19,10 @@ public function register(): void public function boot(): void { $this->loadJsonTranslationsFrom(__DIR__ . '/../lang'); + $this->loadViewsFrom(__DIR__ . '/../resources/views', 'exports'); + + $this->registerRouteMacros(); + $this->registerPermissions(); if ($this->app->runningInConsole()) { $this->registerMigrations(); @@ -26,6 +34,33 @@ public function boot(): void } } + protected function registerRouteMacros(): void + { + Route::macro('exports', function (array $options = []) { + ExportRoutes::register($this, $options); + }); + } + + /** + * view_exports, view_others_exports, delete_exports, delete_others_exports, and the + * default policy, which the app can override + */ + protected function registerPermissions(): void + { + if (! config('exports.register_permissions', true)) { + return; + } + + Permissions::register('exports', [ + 'view_exports' => 'View own exports', + 'view_others_exports' => 'View other users\' exports', + 'delete_exports' => 'Delete own exports', + 'delete_others_exports' => 'Delete other users\' exports', + ], label: 'Exports'); + + Permissions::policy(Exports::exportModel(), config('exports.policy', ExportPolicy::class)); + } + protected function registerMigrations(): void { if (Exports::$runs_migrations) { @@ -42,5 +77,9 @@ protected function registerPublishing(): void $this->publishes([ __DIR__ . '/../database/migrations' => database_path('migrations'), ], 'exports-migrations'); + + $this->publishes([ + __DIR__ . '/../resources/views' => resource_path('views/vendor/exports'), + ], 'exports-views'); } } diff --git a/src/Http/Controllers/ExportsController.php b/src/Http/Controllers/ExportsController.php new file mode 100644 index 0000000..f580bb9 --- /dev/null +++ b/src/Http/Controllers/ExportsController.php @@ -0,0 +1,325 @@ +authorizeAbility($request, 'viewAny', Exports::exportModel()); + + $user = $request->user(); + $sees_others = $user->can('viewOthers', Exports::exportModel()); + $per_page = min(max($request->integer('per_page', 20), 1), 100); + + $query = $this->query($request) + ->with('user') + ->latest('id'); + + if (! $sees_others || $request->boolean('mine')) { + $query->ownedBy($user); + } + + if ($status = ExportStatuses::tryFrom((string) $request->query('status'))) { + $query->status($status); + } + + if ($type = $request->string('type')->toString()) { + $query->where('type', $type); + } + + $exports = $query->paginate($per_page)->withQueryString(); + + if ($request->expectsJson()) { + return ExportResource::collection($exports) + ->additional(['meta' => $this->meta($request)]) + ->response(); + } + + return $this->views->render('exports.index', [ + 'exports' => $this->views->isInertia() + ? ExportResource::collection($exports) + : $exports, + 'available' => $this->availableExports($request), + 'formats' => $this->formats(), + 'sees_others' => $sees_others, + 'filters' => [ + 'status' => $status?->value, + 'type' => $type ?: null, + 'mine' => $request->boolean('mine'), + ], + 'statuses' => collect(ExportStatuses::cases()) + ->map(fn (ExportStatuses $status) => ['value' => $status->value, 'label' => $status->label()]) + ->all(), + ...$this->meta($request), + 'title' => __('Exports'), + ]); + } + + public function store(ExportRequest $request): RedirectResponse|JsonResponse + { + $this->authorizeAbility($request, 'create', Exports::exportModel()); + + $exportable = $this->makeExportable($request, $request->validated('type')); + + abort_unless($this->mayRun($exportable, $request->user()), 403); + + $export = $exportable->queue( + $request->user(), + $request->validated('format'), + $this->safeFilename($request->validated('filename')), + ); + + if ($request->expectsJson()) { + return (new ExportResource($export->refresh())) + ->response() + ->setStatusCode(201); + } + + return redirect() + ->route($this->routeName($request, 'index')) + ->with('success', __('Your export has started. We will let you know when it is ready.')); + } + + public function show(Request $request, string $export): View|Responsable|JsonResponse + { + $export = $this->findExport($request, $export); + + $this->authorizeAbility($request, 'view', $export); + + if ($request->expectsJson()) { + return (new ExportResource($export->load('user'))) + ->additional(['meta' => $this->meta($request)]) + ->response(); + } + + return $this->views->render('exports.show', [ + 'export' => $this->views->isInertia() + ? (new ExportResource($export->load('user')))->resolve($request) + : $export, + ...$this->meta($request), + 'title' => $export->name, + ]); + } + + public function download(Request $request, string $export): StreamedResponse|JsonResponse + { + $export = $this->findExport($request, $export); + + $this->authorizeAbility($request, 'download', $export); + + return $this->downloadResponse($export); + } + + /** + * The link in notifications: signed and expiring, no login needed + */ + public function signedDownload(Request $request, string $export): StreamedResponse|JsonResponse + { + return $this->downloadResponse($this->findExport($request, $export)); + } + + public function cancel(Request $request, string $export): RedirectResponse|JsonResponse + { + $export = $this->findExport($request, $export); + + $this->authorizeAbility($request, 'cancel', $export); + + if (! $export->cancel()) { + return $this->failure($request, __('This export can no longer be cancelled.'), 409); + } + + if ($request->expectsJson()) { + return (new ExportResource($export))->response(); + } + + return back()->with('success', __('Export cancelled')); + } + + public function destroy(Request $request, string $export): RedirectResponse|JsonResponse + { + $export = $this->findExport($request, $export); + + $this->authorizeAbility($request, 'delete', $export); + + $export->cancel(); + $export->delete(); + + if ($request->expectsJson()) { + return response()->json(null, 204); + } + + return redirect() + ->route($this->routeName($request, 'index')) + ->with('success', __('Export deleted')); + } + + protected function downloadResponse(Export $export): StreamedResponse|JsonResponse + { + abort_unless($export->status === ExportStatuses::COMPLETED, 404, __('This export is not ready yet.')); + + abort_if($export->hasExpired() || ! $export->fileExists(), 410, __('This export has expired.')); + + return $export->download(); + } + + protected function query(Request $request): Builder + { + return Exports::exportModel()::query(); + } + + protected function findExport(Request $request, string $uuid): Export + { + return $this->query($request)->where('uuid', $uuid)->firstOrFail(); + } + + /** + * The registered export for a type, made from the request when it has fromRequest() + */ + protected function makeExportable(Request $request, string $type): Exportable + { + $class = Exports::exportClass($type); + + abort_unless($class, 404); + + if (method_exists($class, 'fromRequest')) { + return $class::fromRequest($request); + } + + return new $class; + } + + protected function mayRun(Exportable $exportable, ?Model $user): bool + { + if (! method_exists($exportable, 'authorize')) { + return true; + } + + return (bool) $exportable->authorize($user); + } + + /** + * The registered exports the user may start + * + * @return array + */ + protected function availableExports(Request $request): array + { + $available = []; + + foreach (Exports::registered() as $type => $class) { + $exportable = new $class; + + if (! $this->mayRun($exportable, $request->user())) { + continue; + } + + $available[] = ['type' => $type, 'label' => $exportable->label()]; + } + + return $available; + } + + /** + * @return array + */ + protected function formats(): array + { + return array_map( + fn (Formats $format) => ['value' => $format->value, 'label' => $format->label()], + Formats::enabled(), + ); + } + + /** + * What a page needs to follow progress: broadcasting or polling, and the route names + * + * @return array + */ + protected function meta(Request $request): array + { + return [ + 'broadcasting' => Broadcasting::enabled(), + 'poll_interval' => Broadcasting::pollInterval(), + 'routes' => $this->routeNames($request), + ]; + } + + /** + * A user-supplied file name without paths or characters file systems reject + */ + protected function safeFilename(?string $filename): ?string + { + if ($filename === null) { + return null; + } + + $filename = trim(preg_replace('/[\x00-\x1F\x7F\/\\\\:*?"<>|]+/u', '-', $filename), " .-\t"); + + return $filename === '' ? null : $filename; + } + + protected function failure(Request $request, string $message, int $status): RedirectResponse|JsonResponse + { + if ($request->expectsJson()) { + return response()->json(['message' => $message], $status); + } + + return back()->with('error', $message); + } + + protected function authorizeAbility(Request $request, string $ability, mixed $arguments): void + { + $user = $request->user(); + + abort_unless($user && $user->can($ability, $arguments), 403); + } + + protected function routeName(Request $request, string $action): string + { + return ExportRoutes::namePrefix($request) . ".{$action}"; + } + + /** + * Names of the routes, honouring the prefix the app registered them under + * + * @return array + */ + protected function routeNames(Request $request): array + { + return collect(['index', 'store', 'show', 'download', 'cancel', 'destroy']) + ->mapWithKeys(fn (string $action) => [$action => $this->routeName($request, $action)]) + ->all(); + } +} diff --git a/src/Http/Requests/ExportRequest.php b/src/Http/Requests/ExportRequest.php new file mode 100644 index 0000000..4a1bb98 --- /dev/null +++ b/src/Http/Requests/ExportRequest.php @@ -0,0 +1,43 @@ + + */ + public function rules(): array + { + return [ + 'type' => ['required', 'string', Rule::in(array_keys(Exports::registered()))], + 'format' => ['nullable', 'string', Rule::in(array_map(fn (Formats $format) => $format->value, Formats::enabled()))], + 'filename' => ['nullable', 'string', 'max:200'], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'type' => __('export'), + 'format' => __('format'), + 'filename' => __('file name'), + ]; + } +} diff --git a/src/Http/Resources/ExportResource.php b/src/Http/Resources/ExportResource.php new file mode 100644 index 0000000..fe35346 --- /dev/null +++ b/src/Http/Resources/ExportResource.php @@ -0,0 +1,74 @@ + + */ + public function toArray(Request $request): array + { + $user = $request->user(); + $prefix = ExportRoutes::namePrefix($request); + + return [ + 'id' => $this->uuid, + 'type' => $this->type, + 'name' => $this->name, + 'format' => $this->format->value, + 'format_label' => $this->format->label(), + 'status' => $this->status->value, + 'status_label' => $this->status->label(), + 'progress' => $this->progress(), + 'total_rows' => $this->total_rows, + 'processed_rows' => $this->processed_rows, + 'filename' => $this->filename, + 'size' => $this->size, + 'error' => $this->when($this->status === ExportStatuses::FAILED, fn () => $this->error), + 'is_finished' => $this->status->isFinished(), + 'is_downloadable' => $this->isDownloadable(), + 'is_cancellable' => $this->isCancellable(), + 'user' => $this->whenLoaded('user', fn () => $this->user ? [ + 'type' => $this->user_type, + 'id' => $this->user_id, + 'name' => $this->user->getAttribute('name'), + ] : null), + 'can' => [ + 'download' => (bool) $user?->can('download', $this->resource), + 'cancel' => (bool) $user?->can('cancel', $this->resource), + 'delete' => (bool) $user?->can('delete', $this->resource), + ], + 'links' => [ + 'show' => $this->route("{$prefix}.show"), + 'download' => $this->isDownloadable() ? $this->route("{$prefix}.download") : null, + 'cancel' => $this->route("{$prefix}.cancel"), + 'destroy' => $this->route("{$prefix}.destroy"), + ], + 'broadcast_channel' => Broadcasting::enabled() ? $this->broadcastChannel() : null, + 'started_at' => $this->started_at?->toIso8601String(), + 'completed_at' => $this->completed_at?->toIso8601String(), + 'failed_at' => $this->failed_at?->toIso8601String(), + 'cancelled_at' => $this->cancelled_at?->toIso8601String(), + 'expires_at' => $this->expires_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } + + protected function route(string $name): ?string + { + return Route::has($name) ? route($name, $this->uuid) : null; + } +} diff --git a/src/ModelExport.php b/src/ModelExport.php index 1d2d057..edc3eed 100644 --- a/src/ModelExport.php +++ b/src/ModelExport.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Http\Request; use Illuminate\Queue\SerializesAndRestoresModelIdentifiers; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -205,6 +206,14 @@ public function label(): string return __(Str::of(class_basename($this->modelClass()))->plural()->headline()->toString()); } + /** + * Make the export for the exports API (`POST exports`), e.g. with filters from the request + */ + public static function fromRequest(Request $request): static + { + return new static; + } + /** * Whether the user may run this export. Defaults to the `viewAny` ability on the model. */ diff --git a/src/Policies/ExportPolicy.php b/src/Policies/ExportPolicy.php new file mode 100644 index 0000000..d3eb102 --- /dev/null +++ b/src/Policies/ExportPolicy.php @@ -0,0 +1,85 @@ +allows($user, $this->permission('view'), $this->permission('view_others')); + } + + public function viewOthers(Model $user): bool + { + return $this->allows($user, $this->permission('view_others')); + } + + public function create(Model $user): bool + { + return $this->viewAny($user); + } + + public function view(Model $user, Model $export): bool + { + if ($this->allows($user, $this->permission('view_others'))) { + return true; + } + + return $this->allows($user, $this->permission('view')) && $this->isOwner($user, $export); + } + + public function download(Model $user, Model $export): bool + { + return $this->view($user, $export); + } + + public function cancel(Model $user, Model $export): bool + { + return $this->delete($user, $export); + } + + public function update(Model $user, Model $export): bool + { + return false; + } + + public function forceDelete(Model $user, Model $export): bool + { + return false; + } + + public function restore(Model $user, Model $export): bool + { + return false; + } + + public function viewTrash(Model $user): bool + { + return false; + } + + protected function isOwner(Model $user, Model $model): bool + { + return $model instanceof Export && $model->isOwnedBy($user); + } +} diff --git a/src/Routing/ExportRoutes.php b/src/Routing/ExportRoutes.php new file mode 100644 index 0000000..a83f759 --- /dev/null +++ b/src/Routing/ExportRoutes.php @@ -0,0 +1,82 @@ + 'web_admin', 'prefix' => 'admin/exports', 'name' => 'admin.exports']); + * ``` + */ +class ExportRoutes +{ + /** The route action key that holds the guard the routes were registered for */ + public const string GUARD_ACTION = 'exports_guard'; + + /** + * @param array{ + * guard?: string|null, + * prefix?: string, + * name?: string, + * middleware?: string|array|null, + * controller?: class-string|null, + * signed?: bool, + * signed_middleware?: string|array + * } $options + */ + public static function register(Router $router, array $options = []): void + { + $guard = $options['guard'] ?? null; + $prefix = trim($options['prefix'] ?? 'exports', '/'); + $name = rtrim($options['name'] ?? 'exports', '.'); + $middleware = $options['middleware'] ?? [$guard ? "auth:{$guard}" : 'auth']; + $controller = $options['controller'] ?? config('exports.controllers.exports', ExportsController::class); + + $router->group([ + 'prefix' => $prefix, + 'as' => "{$name}.", + self::GUARD_ACTION => $guard, + ], function (Router $router) use ($controller, $middleware, $options) { + $router->group(['middleware' => $middleware], function (Router $router) use ($controller) { + $router->get('/', [$controller, 'index'])->name('index'); + $router->post('/', [$controller, 'store'])->name('store'); + $router->get('/{export}', [$controller, 'show'])->name('show'); + $router->get('/{export}/download', [$controller, 'download'])->name('download'); + $router->post('/{export}/cancel', [$controller, 'cancel'])->name('cancel'); + $router->delete('/{export}', [$controller, 'destroy'])->name('destroy'); + }); + + if ($options['signed'] ?? true) { + $router->get('/{export}/signed-download', [$controller, 'signedDownload']) + ->middleware($options['signed_middleware'] ?? ['signed']) + ->name('signed-download'); + } + }); + } + + /** + * The guard the current route was registered for, or null for the default guard + */ + public static function guard(Request $request): ?string + { + return $request->route()?->getAction(self::GUARD_ACTION); + } + + /** + * The route name prefix of the current route, e.g. `admin.exports` + */ + public static function namePrefix(Request $request): string + { + $name = (string) $request->route()?->getName(); + + return str_contains($name, '.') + ? substr($name, 0, strrpos($name, '.')) + : 'exports'; + } +} diff --git a/src/Support/Broadcasting.php b/src/Support/Broadcasting.php new file mode 100644 index 0000000..4d545a5 --- /dev/null +++ b/src/Support/Broadcasting.php @@ -0,0 +1,38 @@ + $data + */ + public function render(string $page, array $data = []): View|Responsable + { + if ($this->stack() === self::INERTIA) { + return $this->renderInertia($page, $data); + } + + return view("exports::{$page}", $data); + } + + public function stack(): string + { + return config('exports.views', self::BLADE); + } + + public function isInertia(): bool + { + return $this->stack() === self::INERTIA; + } + + /** + * The Inertia page name for a page: `exports.index` → `exports::Exports/Index` + */ + public function inertiaPage(string $page): string + { + $segments = array_map(fn (string $segment) => str($segment)->studly()->toString(), explode('.', $page)); + + return 'exports::' . implode('/', $segments); + } + + /** + * @param array $data + */ + protected function renderInertia(string $page, array $data): Responsable + { + if (! class_exists(Inertia::class)) { + throw new RuntimeException('Install inertiajs/inertia-laravel to use the inertia views, or set exports.views to blade.'); + } + + return Inertia::render($this->inertiaPage($page), $data); + } +} diff --git a/tests/Feature/StreamingTest.php b/tests/Feature/StreamingTest.php index 830fd83..b6e80f2 100644 --- a/tests/Feature/StreamingTest.php +++ b/tests/Feature/StreamingTest.php @@ -54,7 +54,7 @@ public function it_eager_loads_relations_for_every_chunk(): void UsersExport::make()->writeTo($path = TemporaryFile::path(Formats::CSV)); $role_queries = collect(DB::getQueryLog()) - ->filter(fn (array $query) => str_starts_with($query['query'], 'select * from "roles"')); + ->filter(fn (array $query) => str_starts_with($query['query'], 'select * from "test_roles"')); $this->assertCount(3, $role_queries); $this->assertEquals('ވެރިޔާ', $this->readSpreadsheet($path)[25][10]); diff --git a/tests/Http/ControllerFromConfigTest.php b/tests/Http/ControllerFromConfigTest.php new file mode 100644 index 0000000..b129e68 --- /dev/null +++ b/tests/Http/ControllerFromConfigTest.php @@ -0,0 +1,28 @@ +set('exports.controllers.exports', CustomExportsController::class); + } + + #[Test] + public function the_config_replaces_the_controller(): void + { + $this->actingAs(User::factory()->create()) + ->getJson('/exports') + ->assertJson(['custom' => true]); + } +} diff --git a/tests/Http/ExportsControllerTest.php b/tests/Http/ExportsControllerTest.php new file mode 100644 index 0000000..24e8575 --- /dev/null +++ b/tests/Http/ExportsControllerTest.php @@ -0,0 +1,357 @@ +setUpExports(); + } + + protected function tearDown(): void + { + Exports::forget(); + + parent::tearDown(); + } + + #[Test] + public function guests_cannot_see_exports(): void + { + $this->getJson('/exports')->assertUnauthorized(); + } + + #[Test] + public function it_needs_a_permission_to_list_exports(): void + { + $user = User::factory()->create(); + + $this->actingAs($user)->getJson('/exports')->assertForbidden(); + + $user->givePermissionTo('view_exports'); + + $this->actingAs($user)->getJson('/exports')->assertOk(); + } + + #[Test] + public function users_see_only_their_own_exports(): void + { + $ahmed = User::factory()->create(['name' => 'އަހުމަދު']); + $aisha = User::factory()->create(['name' => 'އައިޝާ']); + + $ahmed->givePermissionTo('view_exports'); + + $own = $this->makeExport($ahmed, ['status' => ExportStatuses::PROCESSING, 'total_rows' => 200, 'processed_rows' => 50]); + $this->makeExport($aisha); + + $this->actingAs($ahmed) + ->getJson('/exports') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $own->uuid) + ->assertJsonPath('data.0.status', 'processing') + ->assertJsonPath('data.0.status_label', 'Processing') + ->assertJsonPath('data.0.progress', 25) + ->assertJsonPath('data.0.user.name', 'އަހުމަދު') + ->assertJsonPath('data.0.links.show', url("/exports/{$own->uuid}")) + ->assertJsonPath('data.0.links.download', null) + ->assertJsonPath('data.0.can.cancel', false) + ->assertJsonPath('meta.broadcasting', false) + ->assertJsonPath('meta.poll_interval', 3000) + ->assertJsonPath('meta.routes.index', 'exports.index'); + } + + #[Test] + public function users_who_view_others_see_everyones_exports(): void + { + $ahmed = User::factory()->create(); + $aisha = User::factory()->create(); + + $ahmed->givePermissionTo('view_others_exports'); + + $this->makeExport($ahmed); + $this->makeExport($aisha, ['status' => ExportStatuses::FAILED, 'error' => 'Oops']); + + $this->actingAs($ahmed)->getJson('/exports')->assertJsonCount(2, 'data'); + $this->actingAs($ahmed)->getJson('/exports?mine=1')->assertJsonCount(1, 'data'); + $this->actingAs($ahmed) + ->getJson('/exports?status=failed') + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.error', 'Oops'); + } + + #[Test] + public function it_starts_a_registered_export(): void + { + $user = User::factory()->create(['name' => 'އަހުމަދު']); + $user->givePermissionTo(['view_exports', 'view_users']); + + $response = $this->actingAs($user) + ->postJson('/exports', ['type' => 'users', 'format' => 'csv', 'filename' => '../ބޭނުންތެރިން/ލިސްޓް']) + ->assertCreated() + ->assertJsonPath('data.type', 'users') + ->assertJsonPath('data.format', 'csv') + ->assertJsonPath('data.status', 'completed') + ->assertJsonPath('data.filename', 'ބޭނުންތެރިން-ލިސްޓް.csv'); + + $export = Export::where('uuid', $response->json('data.id'))->first(); + + $this->assertTrue($export->isOwnedBy($user)); + $this->assertTrue($export->fileExists()); + } + + #[Test] + public function it_needs_permission_to_run_the_export_itself(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('view_exports'); + + $this->actingAs($user) + ->postJson('/exports', ['type' => 'users']) + ->assertForbidden(); + + $this->assertEquals(0, Export::count()); + } + + #[Test] + public function it_validates_the_type_and_format(): void + { + $user = User::factory()->create(); + $user->givePermissionTo(['view_exports', 'view_users']); + + $this->actingAs($user) + ->postJson('/exports', ['type' => 'secrets', 'format' => 'pdf']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['type', 'format']); + } + + #[Test] + public function it_shows_an_export_to_its_owner(): void + { + $ahmed = User::factory()->create(); + $aisha = User::factory()->create(); + + $ahmed->givePermissionTo('view_exports'); + $aisha->givePermissionTo('view_exports'); + + $export = $this->makeExport($ahmed); + + $this->actingAs($ahmed)->getJson("/exports/{$export->uuid}") + ->assertOk() + ->assertJsonPath('data.id', $export->uuid) + ->assertJsonPath('meta.routes.download', 'exports.download'); + + $this->actingAs($aisha)->getJson("/exports/{$export->uuid}")->assertForbidden(); + + $aisha->givePermissionTo('view_others_exports'); + + $this->actingAs($aisha)->getJson("/exports/{$export->uuid}")->assertOk(); + + $this->actingAs($aisha)->getJson('/exports/not-a-uuid')->assertNotFound(); + } + + #[Test] + public function it_downloads_completed_exports(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('view_exports'); + + $export = $this->completedExport($user); + + $response = $this->actingAs($user)->get("/exports/{$export->uuid}/download"); + + $response->assertOk(); + $this->assertStringStartsWith('text/csv', $response->headers->get('Content-Type')); + $this->assertStringContainsString('އަހުމަދު', $response->streamedContent()); + + $pending = $this->makeExport($user); + + $this->actingAs($user)->get("/exports/{$pending->uuid}/download")->assertNotFound(); + + $this->travel(8)->days(); + + $this->actingAs($user)->get("/exports/{$export->uuid}/download")->assertGone(); + } + + #[Test] + public function it_needs_permission_to_download_someone_elses_export(): void + { + $ahmed = User::factory()->create(); + $aisha = User::factory()->create(); + $aisha->givePermissionTo('view_exports'); + + $export = $this->completedExport($ahmed); + + $this->actingAs($aisha)->get("/exports/{$export->uuid}/download")->assertForbidden(); + } + + #[Test] + public function the_signed_link_downloads_without_logging_in(): void + { + $export = $this->completedExport(User::factory()->create()); + + $url = $export->temporaryDownloadUrl(); + + $this->assertStringStartsWith(url("/exports/{$export->uuid}/signed-download"), $url); + + $this->get($url)->assertOk(); + $this->get("{$url}x")->assertForbidden(); + $this->get(url("/exports/{$export->uuid}/signed-download"))->assertForbidden(); + + $this->travel(25)->hours(); + + $this->get($url)->assertForbidden(); + } + + #[Test] + public function the_signed_link_lasts_no_longer_than_the_file(): void + { + $export = $this->completedExport(User::factory()->create()); + $export->forceFill(['expires_at' => now()->addHour()])->save(); + + $url = $export->temporaryDownloadUrl(); + + $this->travel(2)->hours(); + + $this->get($url)->assertForbidden(); + $this->get(URL::temporarySignedRoute('exports.signed-download', now()->addDay(), ['export' => $export->uuid]))->assertGone(); + } + + #[Test] + public function users_cancel_their_own_running_exports(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('view_exports'); + + $export = $this->makeExport($user, ['status' => ExportStatuses::PROCESSING]); + + $this->actingAs($user)->postJson("/exports/{$export->uuid}/cancel")->assertForbidden(); + + $user->givePermissionTo('delete_exports'); + + $this->actingAs($user) + ->postJson("/exports/{$export->uuid}/cancel") + ->assertOk() + ->assertJsonPath('data.status', 'cancelled'); + + $this->actingAs($user) + ->postJson("/exports/{$export->uuid}/cancel") + ->assertStatus(409) + ->assertJsonPath('message', 'This export can no longer be cancelled.'); + } + + #[Test] + public function deleting_an_export_deletes_its_file(): void + { + $ahmed = User::factory()->create(); + $aisha = User::factory()->create(); + $ahmed->givePermissionTo('delete_exports'); + $aisha->givePermissionTo('delete_exports'); + + $export = $this->completedExport($ahmed); + + $this->actingAs($aisha)->deleteJson("/exports/{$export->uuid}")->assertForbidden(); + + $this->actingAs($ahmed)->deleteJson("/exports/{$export->uuid}")->assertNoContent(); + + $this->assertDatabaseMissing('exports', ['id' => $export->id]); + $this->assertFalse($export->fileExists()); + } + + #[Test] + public function admins_use_their_own_guard_and_routes(): void + { + $admin = Admin::factory()->create(['name' => 'މުދިރު']); + $user = User::factory()->create(); + $user->givePermissionTo(['view_exports', 'view_others_exports']); + + $users_export = $this->makeExport($user); + + $this->actingAs($user)->getJson('/admin/exports')->assertUnauthorized(); + + $this->actingAs($admin, 'web_admin')->getJson('/admin/exports')->assertForbidden(); + + $admin->givePermissionTo('view_exports'); + + $admins_export = $this->makeExport($admin); + + $this->actingAs($admin, 'web_admin') + ->getJson('/admin/exports') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $admins_export->uuid) + ->assertJsonPath('data.0.user.type', 'admin') + ->assertJsonPath('data.0.links.show', url("/admin/exports/{$admins_export->uuid}")) + ->assertJsonPath('meta.routes.show', 'admin.exports.show'); + + $this->actingAs($admin, 'web_admin')->getJson("/admin/exports/{$users_export->uuid}")->assertForbidden(); + } + + #[Test] + public function admins_start_exports_owned_by_the_admin(): void + { + $admin = Admin::factory()->create(); + $admin->givePermissionTo(['view_exports', 'view_users']); + + $response = $this->actingAs($admin, 'web_admin') + ->postJson('/admin/exports', ['type' => 'users', 'format' => 'xlsx']) + ->assertCreated(); + + $export = Export::where('uuid', $response->json('data.id'))->first(); + + $this->assertEquals('admin', $export->user_type); + $this->assertEquals($admin->id, $export->user_id); + } + + protected function usesDenyAllPolicy($app): void + { + $app['config']->set('permissions.policies', [Export::class => DenyAllPolicy::class]); + } + + #[Test] + #[DefineEnvironment('usesDenyAllPolicy')] + public function the_app_can_override_the_policy(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('view_exports'); + + $this->actingAs($user)->getJson('/exports')->assertForbidden(); + } + + #[Test] + public function the_app_can_override_the_policy_with_the_gate(): void + { + Gate::policy(Export::class, DenyAllPolicy::class); + + $user = User::factory()->create(); + $user->givePermissionTo('view_exports'); + + $this->actingAs($user)->getJson('/exports')->assertForbidden(); + } + + #[Test] + public function it_registers_the_default_permissions(): void + { + $this->assertDatabaseHas('permissions', ['name' => 'view_exports', 'guard_name' => 'web']); + $this->assertDatabaseHas('permissions', ['name' => 'view_others_exports', 'guard_name' => 'web_admin']); + $this->assertDatabaseHas('permissions', ['name' => 'delete_exports', 'guard_name' => 'web']); + $this->assertDatabaseHas('permissions', ['name' => 'delete_others_exports', 'guard_name' => 'web_admin']); + } +} diff --git a/tests/Http/InteractsWithExports.php b/tests/Http/InteractsWithExports.php new file mode 100644 index 0000000..6a148ee --- /dev/null +++ b/tests/Http/InteractsWithExports.php @@ -0,0 +1,80 @@ +set('exports.disk', 'exports'); + + Permissions::resource('users', 'users', ['view']); + Permissions::policy(User::class, UserPolicy::class); + + Exports::register('users', UsersExport::class); + + $this->artisan('permissions:sync'); + } + + protected function defineRoutes($router): void + { + $router->middleware('web')->group(function ($router) { + $router->exports(); + $router->exports(['guard' => 'web_admin', 'prefix' => 'admin/exports', 'name' => 'admin.exports']); + }); + } + + /** + * @param array $attributes + */ + protected function makeExport(?Model $user, array $attributes = []): Export + { + $export = new Export; + + $export->forceFill(array_merge([ + 'type' => 'users', + 'export_class' => UsersExport::class, + 'name' => 'Users', + 'format' => Formats::CSV, + 'status' => ExportStatuses::PENDING, + 'disk' => 'exports', + 'filename' => 'ބޭނުންތެރިން.csv', + ], $attributes)); + + if ($user) { + $export->user()->associate($user); + } + + $export->save(); + + return $export; + } + + protected function completedExport(?Model $user): Export + { + $export = $this->makeExport($user); + + $path = "exports/{$export->uuid}/ބޭނުންތެރިން.csv"; + Storage::disk('exports')->put($path, "Name\nއަހުމަދު\n"); + + $export->markCompleted('exports', $path, 'ބޭނުންތެރިން.csv', 1, 23); + + return $export; + } +} diff --git a/tests/Http/OverridingControllersTest.php b/tests/Http/OverridingControllersTest.php new file mode 100644 index 0000000..a82e30a --- /dev/null +++ b/tests/Http/OverridingControllersTest.php @@ -0,0 +1,50 @@ +middleware('web')->group(function ($router) { + $router->exports(['prefix' => 'option', 'name' => 'option', 'controller' => CustomExportsController::class]); + $router->exports(['prefix' => 'default', 'name' => 'default']); + $router->exports(['prefix' => 'unsigned', 'name' => 'unsigned', 'signed' => false]); + }); + } + + #[Test] + public function the_route_option_replaces_the_controller(): void + { + $this->actingAs(User::factory()->create()) + ->getJson('/option') + ->assertOk() + ->assertJson(['custom' => true]); + } + + #[Test] + public function the_container_replaces_the_controller(): void + { + $this->app->bind(ExportsController::class, CustomExportsController::class); + + $this->actingAs(User::factory()->create()) + ->getJson('/default') + ->assertJson(['custom' => true]); + } + + #[Test] + public function the_signed_route_can_be_left_out(): void + { + $this->assertTrue(app('router')->has('default.signed-download')); + $this->assertFalse(app('router')->has('unsigned.signed-download')); + $this->assertTrue(app('router')->has('unsigned.download')); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index d1903c0..c52da34 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,26 +3,57 @@ namespace Javaabu\Exports\Tests; use Javaabu\Exports\ExportsServiceProvider; +use Javaabu\Exports\Tests\TestSupport\Models\Admin; +use Javaabu\Exports\Tests\TestSupport\Models\User; use Javaabu\Exports\Tests\TestSupport\Providers\TestServiceProvider; +use Javaabu\Permissions\Models\Permission; +use Javaabu\Permissions\Models\Role; +use Javaabu\Permissions\PermissionsServiceProvider; use OpenSpout\Reader\CSV\Reader as CsvReader; use OpenSpout\Reader\ODS\Reader as OdsReader; use OpenSpout\Reader\ReaderInterface; use OpenSpout\Reader\XLSX\Reader as XlsxReader; use Orchestra\Testbench\TestCase as BaseTestCase; +use Spatie\Permission\PermissionServiceProvider; use ZipArchive; abstract class TestCase extends BaseTestCase { - protected function setUp(): void + protected function defineEnvironment($app): void { - parent::setUp(); - - $this->app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); + $app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); + $app['config']->set('permission.models.permission', Permission::class); + $app['config']->set('permission.models.role', Role::class); + $app['config']->set('permissions.guards', ['web', 'web_admin']); + + $app['config']->set('auth.guards', [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + 'web_admin' => [ + 'driver' => 'session', + 'provider' => 'admins', + ], + ]); + + $app['config']->set('auth.providers', [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => User::class, + ], + 'admins' => [ + 'driver' => 'eloquent', + 'model' => Admin::class, + ], + ]); } protected function getPackageProviders($app): array { return [ + PermissionServiceProvider::class, + PermissionsServiceProvider::class, ExportsServiceProvider::class, TestServiceProvider::class, ]; diff --git a/tests/TestSupport/Factories/AdminFactory.php b/tests/TestSupport/Factories/AdminFactory.php new file mode 100644 index 0000000..79f0b90 --- /dev/null +++ b/tests/TestSupport/Factories/AdminFactory.php @@ -0,0 +1,19 @@ + $this->faker->name, + 'email' => $this->faker->unique()->safeEmail, + ]; + } +} diff --git a/tests/TestSupport/Http/CustomExportsController.php b/tests/TestSupport/Http/CustomExportsController.php new file mode 100644 index 0000000..5fb9579 --- /dev/null +++ b/tests/TestSupport/Http/CustomExportsController.php @@ -0,0 +1,17 @@ +json(['custom' => true]); + } +} diff --git a/tests/TestSupport/Models/Admin.php b/tests/TestSupport/Models/Admin.php new file mode 100644 index 0000000..906fdd7 --- /dev/null +++ b/tests/TestSupport/Models/Admin.php @@ -0,0 +1,25 @@ + User::class, + 'admin' => Admin::class, 'role' => Role::class, 'organization' => Organization::class, ]); diff --git a/tests/TestSupport/database/2024_01_05_223412_create_roles_table.php b/tests/TestSupport/database/2024_01_05_223412_create_roles_table.php index af5ae91..580922b 100644 --- a/tests/TestSupport/database/2024_01_05_223412_create_roles_table.php +++ b/tests/TestSupport/database/2024_01_05_223412_create_roles_table.php @@ -8,7 +8,7 @@ class CreateRolesTable extends Migration { public function up() { - Schema::create('roles', function (Blueprint $table) { + Schema::create('test_roles', function (Blueprint $table) { $table->id(); $table->string('name'); $table->timestamps(); @@ -17,6 +17,6 @@ public function up() public function down() { - Schema::dropIfExists('roles'); + Schema::dropIfExists('test_roles'); } } diff --git a/tests/TestSupport/database/2024_02_05_223412_create_users_table.php b/tests/TestSupport/database/2024_02_05_223412_create_users_table.php index 1d90b1a..e861354 100644 --- a/tests/TestSupport/database/2024_02_05_223412_create_users_table.php +++ b/tests/TestSupport/database/2024_02_05_223412_create_users_table.php @@ -14,7 +14,7 @@ public function up() $table->string('name'); $table->string('password'); $table->boolean('is_admin')->default(false); - $table->foreignId('role_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('role_id')->nullable()->constrained('test_roles')->nullOnDelete(); $table->string('status')->index(); $table->date('birthday')->nullable(); $table->json('settings')->nullable(); diff --git a/tests/TestSupport/database/2024_06_05_223412_create_admins_table.php b/tests/TestSupport/database/2024_06_05_223412_create_admins_table.php new file mode 100644 index 0000000..7e048ba --- /dev/null +++ b/tests/TestSupport/database/2024_06_05_223412_create_admins_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('admins'); + } +}; From db4474824d47ad2487fdec62a6be2eed1157bb2d Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 13:38:53 +0500 Subject: [PATCH 05/13] Broadcast export progress over Reverb, with a polling fallback Export events implement ShouldBroadcastNow and broadcast only when Broadcasting::enabled(): forced by exports.broadcasting.enabled, or on 'auto' when laravel/reverb is installed and the default broadcaster isn't null or log. They go to private-exports.{uuid} (authorized with the policy's view ability) and private-exports.owner.{type}.{id} (the owner only) as export.started, export.progressed, export.completed, export.failed and export.cancelled, with a small progress payload. Without broadcasting, the JSON meta says broadcasting: false and gives the poll interval, so pages poll the show route. laravel/reverb is suggested and installed for tests only. Closes #6 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + composer.json | 5 + config/exports.php | 19 ++ src/Events/Concerns/BroadcastsExport.php | 51 ++++++ src/Events/ExportCancelled.php | 7 +- src/Events/ExportCompleted.php | 7 +- src/Events/ExportFailed.php | 7 +- src/Events/ExportProgressed.php | 7 +- src/Events/ExportStarted.php | 7 +- src/ExportsServiceProvider.php | 14 ++ src/Models/Export.php | 15 +- src/Support/Broadcasting.php | 67 ++++++- tests/Feature/BroadcastingTest.php | 163 ++++++++++++++++++ tests/Feature/PollingTest.php | 54 ++++++ .../Broadcasting/MemoryBroadcaster.php | 51 ++++++ 15 files changed, 466 insertions(+), 9 deletions(-) create mode 100644 src/Events/Concerns/BroadcastsExport.php create mode 100644 tests/Feature/BroadcastingTest.php create mode 100644 tests/Feature/PollingTest.php create mode 100644 tests/TestSupport/Broadcasting/MemoryBroadcaster.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d27d5c..27d3d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,3 +17,4 @@ All notable changes will be documented in this file. - `Route::exports()` registers the exports routes (index, store, show, download, cancel, destroy, signed download) with the app's prefix, name, guard and middleware. `ExportsController` answers JSON or renders pages, and can be replaced from config, the macro or the container. - `ExportPolicy` and the permissions `view_exports`, `view_others_exports`, `delete_exports`, `delete_others_exports`, registered with javaabu/permissions. Exports belong to any user model through a morph; nothing assumes the `web` guard. - `Exports::register('users', UsersExport::class)` lets the API start an export by name. +- Live progress: export events broadcast on private channels (`exports.{uuid}`, `exports.owner.{type}.{id}`) when laravel/reverb is installed and broadcasting is on (`exports.broadcasting.enabled`: auto, true or false). The API tells pages whether to listen or to poll, and how often. diff --git a/composer.json b/composer.json index b13ffe6..d625059 100644 --- a/composer.json +++ b/composer.json @@ -41,9 +41,14 @@ "require-dev": { "javaabu/boost-javaabu-guidelines": "^1.0", "laravel/pint": "^1.24", + "laravel/reverb": "^1.12", "orchestra/testbench": "^10.0 || ^11.0", "phpunit/phpunit": "^11.5.3 || ^12.5.8" }, + "suggest": { + "laravel/reverb": "Broadcasts export progress live; pages poll without it (^1.12)", + "inertiajs/inertia-laravel": "Renders the exports pages with Inertia React when exports.views is inertia (^2.0)" + }, "autoload": { "psr-4": { "Javaabu\\Exports\\": "src/" diff --git a/config/exports.php b/config/exports.php index d7e47cd..0c42f31 100644 --- a/config/exports.php +++ b/config/exports.php @@ -237,6 +237,25 @@ 'signed_download' => 'exports.signed-download', ], + /* + |-------------------------------------------------------------------------- + | Live progress + |-------------------------------------------------------------------------- + | + | Progress is broadcast on private channels when enabled is true, or when + | it's 'auto' and laravel/reverb is installed with a real broadcaster as the + | default. Otherwise pages poll the show route every poll_interval ms. + | guards are the guards that authorize the channels (null: the defaults). + | + */ + + 'broadcasting' => [ + 'enabled' => env('EXPORTS_BROADCASTING', 'auto'), + 'channel' => 'exports', + 'guards' => null, + 'poll_interval' => 3000, + ], + /* |-------------------------------------------------------------------------- | Views diff --git a/src/Events/Concerns/BroadcastsExport.php b/src/Events/Concerns/BroadcastsExport.php new file mode 100644 index 0000000..a62217e --- /dev/null +++ b/src/Events/Concerns/BroadcastsExport.php @@ -0,0 +1,51 @@ + + */ + public function broadcastOn(): array + { + $channels = [new PrivateChannel($this->export->broadcastChannel())]; + + if ($owner_channel = $this->export->ownerBroadcastChannel()) { + $channels[] = new PrivateChannel($owner_channel); + } + + return $channels; + } + + /** + * `export.progressed`, `export.completed`, … + */ + public function broadcastAs(): string + { + return 'export.' . strtolower(substr(class_basename(static::class), strlen('Export'))); + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return Broadcasting::payload($this->export); + } +} diff --git a/src/Events/ExportCancelled.php b/src/Events/ExportCancelled.php index cb261f1..11ea2d2 100644 --- a/src/Events/ExportCancelled.php +++ b/src/Events/ExportCancelled.php @@ -2,15 +2,20 @@ namespace Javaabu\Exports\Events; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Javaabu\Exports\Events\Concerns\BroadcastsExport; use Javaabu\Exports\Models\Export; /** * An export was cancelled + * + * Broadcast as `export.cancelled` when broadcasting is enabled. */ -class ExportCancelled +class ExportCancelled implements ShouldBroadcastNow { + use BroadcastsExport; use Dispatchable; use SerializesModels; diff --git a/src/Events/ExportCompleted.php b/src/Events/ExportCompleted.php index 9f78407..b7d7a98 100644 --- a/src/Events/ExportCompleted.php +++ b/src/Events/ExportCompleted.php @@ -2,15 +2,20 @@ namespace Javaabu\Exports\Events; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Javaabu\Exports\Events\Concerns\BroadcastsExport; use Javaabu\Exports\Models\Export; /** * An export's file is ready to download + * + * Broadcast as `export.completed` when broadcasting is enabled. */ -class ExportCompleted +class ExportCompleted implements ShouldBroadcastNow { + use BroadcastsExport; use Dispatchable; use SerializesModels; diff --git a/src/Events/ExportFailed.php b/src/Events/ExportFailed.php index d7d7d4d..e7f6740 100644 --- a/src/Events/ExportFailed.php +++ b/src/Events/ExportFailed.php @@ -2,15 +2,20 @@ namespace Javaabu\Exports\Events; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Javaabu\Exports\Events\Concerns\BroadcastsExport; use Javaabu\Exports\Models\Export; /** * An export failed + * + * Broadcast as `export.failed` when broadcasting is enabled. */ -class ExportFailed +class ExportFailed implements ShouldBroadcastNow { + use BroadcastsExport; use Dispatchable; use SerializesModels; diff --git a/src/Events/ExportProgressed.php b/src/Events/ExportProgressed.php index 258bbe0..d81f1aa 100644 --- a/src/Events/ExportProgressed.php +++ b/src/Events/ExportProgressed.php @@ -2,15 +2,20 @@ namespace Javaabu\Exports\Events; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Javaabu\Exports\Events\Concerns\BroadcastsExport; use Javaabu\Exports\Models\Export; /** * An export wrote another chunk of rows + * + * Broadcast as `export.progressed` when broadcasting is enabled. */ -class ExportProgressed +class ExportProgressed implements ShouldBroadcastNow { + use BroadcastsExport; use Dispatchable; use SerializesModels; diff --git a/src/Events/ExportStarted.php b/src/Events/ExportStarted.php index 956e96b..4c12920 100644 --- a/src/Events/ExportStarted.php +++ b/src/Events/ExportStarted.php @@ -2,15 +2,20 @@ namespace Javaabu\Exports\Events; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Javaabu\Exports\Events\Concerns\BroadcastsExport; use Javaabu\Exports\Models\Export; /** * An export started processing + * + * Broadcast as `export.started` when broadcasting is enabled. */ -class ExportStarted +class ExportStarted implements ShouldBroadcastNow { + use BroadcastsExport; use Dispatchable; use SerializesModels; diff --git a/src/ExportsServiceProvider.php b/src/ExportsServiceProvider.php index 0c4c2ab..bf1e57e 100644 --- a/src/ExportsServiceProvider.php +++ b/src/ExportsServiceProvider.php @@ -7,6 +7,7 @@ use Javaabu\Exports\Commands\PruneExportsCommand; use Javaabu\Exports\Policies\ExportPolicy; use Javaabu\Exports\Routing\ExportRoutes; +use Javaabu\Exports\Support\Broadcasting; use Javaabu\Permissions\Permissions; class ExportsServiceProvider extends ServiceProvider @@ -23,6 +24,7 @@ public function boot(): void $this->registerRouteMacros(); $this->registerPermissions(); + $this->registerBroadcastChannels(); if ($this->app->runningInConsole()) { $this->registerMigrations(); @@ -61,6 +63,18 @@ protected function registerPermissions(): void Permissions::policy(Exports::exportModel(), config('exports.policy', ExportPolicy::class)); } + /** + * Authorize the progress channels when broadcasting is on (Reverb installed, or forced) + */ + protected function registerBroadcastChannels(): void + { + if (! Broadcasting::enabled()) { + return; + } + + Broadcasting::registerChannels(); + } + protected function registerMigrations(): void { if (Exports::$runs_migrations) { diff --git a/src/Models/Export.php b/src/Models/Export.php index 4407359..8b874a4 100644 --- a/src/Models/Export.php +++ b/src/Models/Export.php @@ -14,6 +14,7 @@ use Javaabu\Exports\Enums\ExportStatuses; use Javaabu\Exports\Enums\Formats; use Javaabu\Exports\Events\ExportCancelled; +use Javaabu\Exports\Support\Broadcasting; use Symfony\Component\HttpFoundation\StreamedResponse; use Throwable; @@ -299,7 +300,19 @@ public function temporaryDownloadUrl(?DateTimeInterface $expires_at = null): ?st */ public function broadcastChannel(): string { - return config('exports.broadcasting.channel', 'exports') . ".{$this->uuid}"; + return Broadcasting::channelPrefix() . ".{$this->uuid}"; + } + + /** + * The private broadcast channel for every export of this export's owner + */ + public function ownerBroadcastChannel(): ?string + { + if (! $this->user_type || $this->user_id === null) { + return null; + } + + return Broadcasting::channelPrefix() . '.owner.' . Broadcasting::encodeType($this->user_type) . ".{$this->user_id}"; } public static function expiresAfterDays(): int diff --git a/src/Support/Broadcasting.php b/src/Support/Broadcasting.php index 4d545a5..393eab2 100644 --- a/src/Support/Broadcasting.php +++ b/src/Support/Broadcasting.php @@ -2,13 +2,20 @@ namespace Javaabu\Exports\Support; -use Laravel\Reverb\ReverbServiceProvider; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Broadcast; +use Javaabu\Exports\Exports; +use Javaabu\Exports\Models\Export; /** - * Whether export progress is broadcast, and how often pages should poll otherwise + * Whether export progress is broadcast (over Laravel Reverb), the channels it uses, + * and how often pages poll when it isn't */ class Broadcasting { + /** The class whose presence means laravel/reverb is installed */ + public static string $reverb_class = 'Laravel\\Reverb\\ReverbServiceProvider'; + /** * `exports.broadcasting.enabled`: true, false, or 'auto' (when laravel/reverb is * installed and the default broadcaster isn't null or log) @@ -21,7 +28,7 @@ public static function enabled(): bool return (bool) $enabled; } - if (! class_exists(ReverbServiceProvider::class)) { + if (! class_exists(static::$reverb_class)) { return false; } @@ -35,4 +42,58 @@ public static function pollInterval(): int { return max((int) config('exports.broadcasting.poll_interval', 3000), 500); } + + public static function channelPrefix(): string + { + return (string) config('exports.broadcasting.channel', 'exports'); + } + + /** + * Authorize the private channels: `exports.{uuid}` for whoever may view the export, + * `exports.owner.{type}.{id}` for the owner + */ + public static function registerChannels(): void + { + $prefix = static::channelPrefix(); + $options = array_filter(['guards' => config('exports.broadcasting.guards')]); + + Broadcast::channel("{$prefix}.owner.{type}.{id}", function (Model $user, string $type, string $id) { + return static::encodeType($user->getMorphClass()) === $type + && (string) $user->getKey() === $id; + }, $options); + + Broadcast::channel("{$prefix}.{uuid}", function (Model $user, string $uuid) { + $export = Exports::exportModel()::query()->where('uuid', $uuid)->first(); + + return $export !== null && $user->can('view', $export); + }, $options); + } + + /** + * A morph type that's safe in a channel name: `App\Models\User` → `App-Models-User` + */ + public static function encodeType(string $type): string + { + return str_replace(['\\', '.'], ['-', '='], $type); + } + + /** + * What progress events send: enough for a page to update a row + * + * @return array + */ + public static function payload(Export $export): array + { + return [ + 'id' => $export->uuid, + 'status' => $export->status->value, + 'status_label' => $export->status->label(), + 'progress' => $export->progress(), + 'total_rows' => $export->total_rows, + 'processed_rows' => $export->processed_rows, + 'is_finished' => $export->status->isFinished(), + 'is_downloadable' => $export->isDownloadable(), + 'error' => $export->error, + ]; + } } diff --git a/tests/Feature/BroadcastingTest.php b/tests/Feature/BroadcastingTest.php new file mode 100644 index 0000000..2f0100c --- /dev/null +++ b/tests/Feature/BroadcastingTest.php @@ -0,0 +1,163 @@ +broadcaster = new MemoryBroadcaster; + + $app->make(BroadcastManager::class)->extend('memory', fn () => $broadcaster); + + $app['config']->set('broadcasting.connections.memory', ['driver' => 'memory']); + $app['config']->set('broadcasting.default', 'memory'); + $app['config']->set('exports.broadcasting.enabled', true); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpExports(); + } + + protected function tearDown(): void + { + Exports::forget(); + Broadcasting::$reverb_class = 'Laravel\\Reverb\\ReverbServiceProvider'; + + parent::tearDown(); + } + + #[Test] + public function it_broadcasts_progress_on_the_export_and_owner_channels(): void + { + config()->set('exports.queue.progress_every', 10); + + $user = User::factory()->create(); + $this->seedUsers(19); + + $export = UsersExport::make()->queue($user, 'csv'); + + $events = array_column($this->broadcaster->broadcasts, 'event'); + + $this->assertEquals(['export.started', 'export.progressed', 'export.progressed', 'export.completed'], $events); + + $completed = end($this->broadcaster->broadcasts); + + $this->assertEquals([ + "private-exports.{$export->uuid}", + "private-exports.owner.user.{$user->id}", + ], $completed['channels']); + + $this->assertEquals($export->uuid, $completed['payload']['id']); + $this->assertEquals('completed', $completed['payload']['status']); + $this->assertEquals(100, $completed['payload']['progress']); + $this->assertTrue($completed['payload']['is_downloadable']); + } + + #[Test] + public function it_broadcasts_cancellation(): void + { + $export = $this->makeExport(User::factory()->create(), ['status' => ExportStatuses::PROCESSING]); + + $export->cancel(); + + $this->assertEquals('export.cancelled', $this->broadcaster->broadcasts[0]['event']); + } + + #[Test] + public function it_authorizes_the_export_channel_with_the_policy(): void + { + $ahmed = User::factory()->create(); + $aisha = User::factory()->create(); + $ahmed->givePermissionTo('view_exports'); + $aisha->givePermissionTo('view_exports'); + + $export = $this->makeExport($ahmed); + + $this->assertTrue($this->broadcaster->authorize("exports.{$export->uuid}", $ahmed)); + $this->assertFalse($this->broadcaster->authorize("exports.{$export->uuid}", $aisha)); + $this->assertFalse($this->broadcaster->authorize('exports.missing', $ahmed)); + } + + #[Test] + public function it_authorizes_the_owner_channel_for_the_owner_only(): void + { + $user = User::factory()->create(); + $admin = Admin::factory()->create(['id' => $user->id]); + + $this->assertTrue($this->broadcaster->authorize("exports.owner.user.{$user->id}", $user)); + $this->assertFalse($this->broadcaster->authorize("exports.owner.user.{$user->id}", $admin)); + $this->assertTrue($this->broadcaster->authorize("exports.owner.admin.{$admin->id}", $admin)); + } + + #[Test] + public function the_api_tells_pages_which_channel_to_listen_on(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('view_exports'); + + $export = $this->makeExport($user); + + $this->actingAs($user) + ->getJson("/exports/{$export->uuid}") + ->assertJsonPath('data.broadcast_channel', "exports.{$export->uuid}") + ->assertJsonPath('meta.broadcasting', true); + } + + #[Test] + public function it_detects_reverb(): void + { + config()->set('exports.broadcasting.enabled', 'auto'); + config()->set('broadcasting.default', 'reverb'); + + $this->assertTrue(Broadcasting::enabled()); + + config()->set('broadcasting.default', 'null'); + $this->assertFalse(Broadcasting::enabled()); + + config()->set('broadcasting.default', 'log'); + $this->assertFalse(Broadcasting::enabled()); + + config()->set('broadcasting.default', 'reverb'); + Broadcasting::$reverb_class = 'Missing\\ReverbServiceProvider'; + $this->assertFalse(Broadcasting::enabled()); + + config()->set('exports.broadcasting.enabled', true); + $this->assertTrue(Broadcasting::enabled()); + } + + #[Test] + public function it_encodes_class_morph_types_for_channel_names(): void + { + $this->assertEquals('App-Models-User', Broadcasting::encodeType('App\\Models\\User')); + + $export = $this->makeExport(null); + + $this->assertNull($export->ownerBroadcastChannel()); + $this->assertEquals(["private-exports.{$export->uuid}"], array_map('strval', (new ExportProgressed($export))->broadcastOn())); + } +} diff --git a/tests/Feature/PollingTest.php b/tests/Feature/PollingTest.php new file mode 100644 index 0000000..9cfde02 --- /dev/null +++ b/tests/Feature/PollingTest.php @@ -0,0 +1,54 @@ +setUpExports(); + } + + protected function tearDown(): void + { + Exports::forget(); + + parent::tearDown(); + } + + #[Test] + public function without_reverb_nothing_is_broadcast_and_pages_poll(): void + { + config()->set('exports.broadcasting.poll_interval', 1500); + + $this->assertFalse(Broadcasting::enabled()); + $this->assertEmpty(Broadcast::getChannels()); + + $user = User::factory()->create(); + $user->givePermissionTo('view_exports'); + + $export = $this->makeExport($user); + + $this->assertFalse((new ExportProgressed($export))->broadcastWhen()); + + $this->actingAs($user) + ->getJson("/exports/{$export->uuid}") + ->assertJsonPath('data.broadcast_channel', null) + ->assertJsonPath('data.progress', null) + ->assertJsonPath('meta.broadcasting', false) + ->assertJsonPath('meta.poll_interval', 1500); + } +} diff --git a/tests/TestSupport/Broadcasting/MemoryBroadcaster.php b/tests/TestSupport/Broadcasting/MemoryBroadcaster.php new file mode 100644 index 0000000..1ccd111 --- /dev/null +++ b/tests/TestSupport/Broadcasting/MemoryBroadcaster.php @@ -0,0 +1,51 @@ +, event: string, payload: array}> */ + public array $broadcasts = []; + + public function auth($request): mixed + { + return true; + } + + public function validAuthenticationResponse($request, $result): mixed + { + return $result; + } + + public function broadcast(array $channels, $event, array $payload = []): void + { + $this->broadcasts[] = [ + 'channels' => array_map('strval', $channels), + 'event' => $event, + 'payload' => $payload, + ]; + } + + /** + * Run a channel's authorization callback for a user + */ + public function authorize(string $channel, mixed $user): mixed + { + foreach ($this->channels as $pattern => $callback) { + if (! $this->channelNameMatchesPattern($channel, $pattern)) { + continue; + } + + $parameters = $this->extractAuthParameters($pattern, $channel, $callback); + + return $callback($user, ...$parameters); + } + + return null; + } +} From 511b04ad021619ef9b45a75f414adf9ced713faf Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 13:39:54 +0500 Subject: [PATCH 06/13] Add English and Dhivehi translations for every user-facing string lang/en.json and lang/dv.json cover statuses, formats, messages, notifications, permission descriptions and the common default headings. A test fails when a __() string in src or the views has no Dhivehi line. Refs #9 Co-Authored-By: Claude Opus 5.5 --- lang/dv.json | 34 +++++++++++++++- lang/en.json | 39 ++++++++++++++++++ tests/Unit/TranslationsTest.php | 71 +++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/TranslationsTest.php diff --git a/lang/dv.json b/lang/dv.json index 8034ae4..45734c5 100644 --- a/lang/dv.json +++ b/lang/dv.json @@ -4,6 +4,38 @@ "ID": "އައިޑީ", "Name": "ނަން", "Email": "އީމެއިލް", + "Status": "ހާލަތު", + "Created At": "އުފެއްދި ތާރީޚް", + "Updated At": "ބަދަލުކުރި ތާރީޚް", + "Deleted At": "ފުހެލި ތާރީޚް", + "CSV": "CSV", + "Excel (XLSX)": "އެކްސެލް (XLSX)", + "OpenDocument (ODS)": "އޯޕަން ޑޮކިއުމަންޓް (ODS)", + "Unsupported export format: :format": "ސަޕޯޓް ނުކުރާ އެކްސްޕޯޓް ފޯމެޓެއް: :format", + "Pending": "މަޑުކުރަނީ", + "Processing": "ތައްޔާރުކުރަނީ", + "Completed": "ނިމިއްޖެ", + "Failed": "ނާކާމިޔާބުވެއްޖެ", + "Cancelled": "ކެންސަލްކުރެވިއްޖެ", + "Exports": "އެކްސްޕޯޓްތައް", + "export": "އެކްސްޕޯޓް", + "format": "ފޯމެޓް", + "file name": "ފައިލުގެ ނަން", + "Download": "ޑައުންލޯޑު", + "Export cancelled": "އެކްސްޕޯޓް ކެންސަލްކުރެވިއްޖެ", + "Export deleted": "އެކްސްޕޯޓް ފުހެލެވިއްޖެ", + "The export failed.": "އެކްސްޕޯޓް ނާކާމިޔާބުވެއްޖެ.", + "This export can no longer be cancelled.": "މި އެކްސްޕޯޓް މިހާރު ކެންސަލެއް ނުކުރެވޭނެ.", + "This export has expired.": "މި އެކްސްޕޯޓުގެ މުއްދަތު ހަމަވެއްޖެ.", + "This export is not ready yet.": "މި އެކްސްޕޯޓް އަދި ތައްޔާރެއް ނުވޭ.", + "Your export has started. We will let you know when it is ready.": "އެކްސްޕޯޓް ފެށިއްޖެ. ތައްޔާރުވުމުން އަންގާނަން.", "Your :name export is ready": "ތިބާގެ :name އެކްސްޕޯޓް ތައްޔާރުވެއްޖެ", - "Your :name export failed": "ތިބާގެ :name އެކްސްޕޯޓް ނާކާމިޔާބުވެއްޖެ" + "Your :name export with :count rows is ready to download.": "ތިބާގެ :count ރޯގެ :name އެކްސްޕޯޓް ޑައުންލޯޑުކުރުމަށް ތައްޔާރުވެއްޖެ.", + "The file will be deleted on :date.": ":date ގައި ފައިލު ފުހެލެވޭނެ.", + "Your :name export failed": "ތިބާގެ :name އެކްސްޕޯޓް ނާކާމިޔާބުވެއްޖެ", + "Your :name export could not be completed. Please try again, or contact us if it keeps failing.": "ތިބާގެ :name އެކްސްޕޯޓް ނިންމޭކަށް ނުކެރުނު. އަލުން މަސައްކަތްކޮށްލައްވާ. ދެން ވެސް ނާކާމިޔާބުވާނަމަ އަޅުގަނޑުމެންނާ ގުޅުއްވާ.", + "View own exports": "އަމިއްލަ އެކްސްޕޯޓްތައް ބެލުން", + "View other users' exports": "އެހެން ފަރާތްތަކުގެ އެކްސްޕޯޓްތައް ބެލުން", + "Delete own exports": "އަމިއްލަ އެކްސްޕޯޓްތައް ފުހެލުން", + "Delete other users' exports": "އެހެން ފަރާތްތަކުގެ އެކްސްޕޯޓްތައް ފުހެލުން" } diff --git a/lang/en.json b/lang/en.json index 2c63c08..6fde7a8 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,2 +1,41 @@ { + "Yes": "Yes", + "No": "No", + "ID": "ID", + "Name": "Name", + "Email": "Email", + "Status": "Status", + "Created At": "Created At", + "Updated At": "Updated At", + "Deleted At": "Deleted At", + "CSV": "CSV", + "Excel (XLSX)": "Excel (XLSX)", + "OpenDocument (ODS)": "OpenDocument (ODS)", + "Unsupported export format: :format": "Unsupported export format: :format", + "Pending": "Pending", + "Processing": "Processing", + "Completed": "Completed", + "Failed": "Failed", + "Cancelled": "Cancelled", + "Exports": "Exports", + "export": "export", + "format": "format", + "file name": "file name", + "Download": "Download", + "Export cancelled": "Export cancelled", + "Export deleted": "Export deleted", + "The export failed.": "The export failed.", + "This export can no longer be cancelled.": "This export can no longer be cancelled.", + "This export has expired.": "This export has expired.", + "This export is not ready yet.": "This export is not ready yet.", + "Your export has started. We will let you know when it is ready.": "Your export has started. We will let you know when it is ready.", + "Your :name export is ready": "Your :name export is ready", + "Your :name export with :count rows is ready to download.": "Your :name export with :count rows is ready to download.", + "The file will be deleted on :date.": "The file will be deleted on :date.", + "Your :name export failed": "Your :name export failed", + "Your :name export could not be completed. Please try again, or contact us if it keeps failing.": "Your :name export could not be completed. Please try again, or contact us if it keeps failing.", + "View own exports": "View own exports", + "View other users' exports": "View other users' exports", + "Delete own exports": "Delete own exports", + "Delete other users' exports": "Delete other users' exports" } diff --git a/tests/Unit/TranslationsTest.php b/tests/Unit/TranslationsTest.php new file mode 100644 index 0000000..22e9abe --- /dev/null +++ b/tests/Unit/TranslationsTest.php @@ -0,0 +1,71 @@ +translatableStrings(), array_keys($dv))); + + $this->assertEquals([], $missing, 'Add these to lang/dv.json'); + $this->assertEquals(array_keys($dv), array_keys($en), 'lang/en.json and lang/dv.json should have the same keys'); + } + + #[Test] + public function it_translates_into_dhivehi(): void + { + app()->setLocale('dv'); + + $this->assertEquals('ނިމިއްޖެ', ExportStatuses::COMPLETED->label()); + $this->assertEquals('އަމިއްލަ އެކްސްޕޯޓްތައް ބެލުން', __('View own exports')); + } + + /** + * Literal strings passed to __() in src and resources, outside comments + * + * @return array + */ + protected function translatableStrings(): array + { + $strings = []; + + foreach (['src', 'resources/views', 'resources/js'] as $directory) { + $path = __DIR__ . "/../../{$directory}"; + + if (! is_dir($path)) { + continue; + } + + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) { + foreach (file($file->getPathname()) as $line) { + if (preg_match('/^\s*(\*|\/\/|\/\*)/', $line)) { + continue; + } + + preg_match_all("/(?])(?:__|t)\(\s*'((?:[^'\\\\]|\\\\.)*)'/", $line, $matches); + + foreach ($matches[1] as $string) { + $strings[] = stripslashes($string); + } + } + } + } + + foreach (['View own exports', "View other users' exports", 'Delete own exports', "Delete other users' exports"] as $permission) { + $strings[] = $permission; + } + + return array_values(array_unique($strings)); + } +} From 5b966e34b0b82bc504678875d52db72bd5dcb41a Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 13:46:43 +0500 Subject: [PATCH 07/13] Document v2 for people and agents; add the Boost guideline and skill Docs for installing, model exports, downloading and storing, queued exports, the exports page and API, performance (with the benchmark), permissions and policies with multiple guards, live progress, translations, configuration, an API reference, AI assistants, and upgrading from v1 with the reasons for dropping maatwebsite/excel and how to migrate. llms.txt indexes every page. AGENTS.md and CLAUDE.md for agents working on the repo. Queued exports now write the file in the locale active when queue() was called. Broadcasting's enabled flag accepts boolean strings. The pages for the views come with the views. Closes #8 Refs #7 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 45 ++++ CLAUDE.md | 1 + docs/advanced-usage/_category_.json | 11 + docs/advanced-usage/api-reference.md | 125 +++++++++++ docs/advanced-usage/configuration.md | 81 +++++++ docs/advanced-usage/live-progress.md | 72 ++++++ docs/advanced-usage/performance.md | 51 +++++ .../permissions-and-policies.md | 100 +++++++++ docs/advanced-usage/translations.md | 35 +++ docs/ai-assistants.md | 34 +++ docs/basic-usage/downloading-and-storing.md | 63 ++++++ docs/basic-usage/exports-page-and-api.md | 177 +++++++++++++++ docs/basic-usage/model-export.md | 150 ------------- docs/basic-usage/model-exports.md | 205 ++++++++++++++++++ docs/basic-usage/queued-exports.md | 148 +++++++++++++ docs/installation-and-setup.md | 93 +++++++- docs/intro.md | 29 ++- docs/metadata.json | 4 +- docs/requirements.md | 14 +- docs/upgrading.md | 98 +++++++++ llms.txt | 42 ++++ resources/boost/guidelines/core.blade.php | 10 + .../boost/skills/javaabu-exports/SKILL.md | 90 ++++++++ .../references/common-mistakes.md | 90 ++++++++ .../references/queued-exports.md | 43 ++++ src/Jobs/ProcessExport.php | 19 ++ src/Support/Broadcasting.php | 2 +- tests/Feature/BroadcastingTest.php | 3 + tests/Feature/QueuedExportsTest.php | 23 ++ 29 files changed, 1695 insertions(+), 163 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/advanced-usage/_category_.json create mode 100644 docs/advanced-usage/api-reference.md create mode 100644 docs/advanced-usage/configuration.md create mode 100644 docs/advanced-usage/live-progress.md create mode 100644 docs/advanced-usage/performance.md create mode 100644 docs/advanced-usage/permissions-and-policies.md create mode 100644 docs/advanced-usage/translations.md create mode 100644 docs/ai-assistants.md create mode 100644 docs/basic-usage/downloading-and-storing.md create mode 100644 docs/basic-usage/exports-page-and-api.md delete mode 100644 docs/basic-usage/model-export.md create mode 100644 docs/basic-usage/model-exports.md create mode 100644 docs/basic-usage/queued-exports.md create mode 100644 docs/upgrading.md create mode 100644 llms.txt create mode 100644 resources/boost/guidelines/core.blade.php create mode 100644 resources/boost/skills/javaabu-exports/SKILL.md create mode 100644 resources/boost/skills/javaabu-exports/references/common-mistakes.md create mode 100644 resources/boost/skills/javaabu-exports/references/queued-exports.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cae848c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# exports: notes for AI coding agents + +`javaabu/exports` writes Eloquent models (or any rows) to CSV, XLSX and ODS with OpenSpout, streaming so memory stays flat. It adds queued exports tracked by an `Export` model, notifications, broadcasting, routes with an overridable controller and JSON API, Blade and Inertia React pages, and a policy with permissions from `javaabu/permissions`. Apps subclass `ModelExport`, so treat its public and protected methods, `Column`, `Exportable`, `WritesFiles`, `Exports`, `Route::exports()` and the JSON shape of `ExportResource` as public contracts. + +## Layout + +| Path | What | +| --- | --- | +| `src/ModelExport.php` | Base class: default columns, `columns()`, headings, formatting, chunked reading, queue serialization | +| `src/IterableExport.php`, `src/Contracts`, `src/Concerns/WritesFiles.php` | Non-model exports, the `Exportable` contract, `download()`/`store()`/`writeTo()`/`queue()` | +| `src/Columns/Column.php` | Column definitions | +| `src/Writers/SpreadsheetWriter.php` | OpenSpout writer: cells, formula escaping, widths, RTL | +| `src/Models/Export.php`, `src/Enums` | The Export model, `Formats`, `ExportStatuses` | +| `src/Jobs/ProcessExport.php`, `src/Events`, `src/Notifications`, `src/Commands` | The queued job, events (broadcastable), notifications, `exports:prune` | +| `src/Support` | `SerializableQuery`, `Broadcasting`, `TemporaryFile` | +| `src/Routing`, `src/Http`, `src/Policies`, `src/Views` | `Route::exports()`, controller, request, resource, policy, `ViewRenderer` | +| `config/exports.php`, `database/migrations` | Options; the `exports` table | +| `resources/views` | Blade pages (javaabu/forms components) | +| `resources/js` | Inertia React pages (TypeScript), shipped through javaabu/manifest | +| `lang/en.json`, `lang/dv.json` | Translations; English strings are the keys | +| `docs`, `llms.txt` | Docusaurus pages for docs.javaabu.com and the index for AI assistants | +| `resources/boost` | Laravel Boost guideline and skill, shipped in the package | +| `tests` | Testbench tests; two guards (`web` → `User`, `web_admin` → `Admin`); `tests/Benchmark` is the 1M-row benchmark | + +## Rules + +- Follow Javaabu codestyle ([boost-javaabu-guidelines](https://github.com/Javaabu/boost-javaabu-guidelines)): snake_case variables and properties, camelCase methods, early returns, `#[Test]` and `it_…` test names. Pint enforces formatting. +- Memory must stay flat: never collect rows into arrays, never `get()` a whole query, never buffer a file in memory. `composer benchmark` and `StreamingTest` guard this. +- Strings are written as text cells; never let user data become a formula. +- Nothing may assume the `web` guard or `App\Models\User`. Type-hint users as `Model`; exports belong to users through the `user` morph. Test on both guards. +- Reverb and Inertia are optional: feature-detect them, keep them in `suggest`, and test both ways. +- v1 subclasses must keep working: don't rename or retype `ModelExport` hooks without an entry in `docs/upgrading.md`. +- Every user-facing string goes through `__()` with an English key, and gets a line in `lang/en.json` and `lang/dv.json` (`TranslationsTest` checks). Use Thaana in tests of user-facing behaviour. List uncertain translations in `docs/advanced-usage/translations.md`. Views use logical CSS only (`ms-`, `pe-`, `start-`), never `left`/`right`. +- Every change needs tests, docs (linked from `llms.txt`), the Boost skill if it changes how the package is used, and a line in `CHANGELOG.md`. +- Work on the `next-gen` branch; don't commit to `main`. + +## Commands + +```bash +composer install +composer lint # vendor/bin/pint --test +composer format # vendor/bin/pint +composer test # vendor/bin/phpunit +composer benchmark # 1M rows; EXPORTS_BENCHMARK_ROWS to change +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/advanced-usage/_category_.json b/docs/advanced-usage/_category_.json new file mode 100644 index 0000000..df1b976 --- /dev/null +++ b/docs/advanced-usage/_category_.json @@ -0,0 +1,11 @@ +{ + "position": 1.31, + "label": "Advanced Usage", + "collapsible": true, + "collapsed": false, + "link": { + "type": "generated-index", + "slug": "/exports/_categories/advanced-usage", + "description": "Performance, live progress, permissions, overrides and every option." + } +} diff --git a/docs/advanced-usage/api-reference.md b/docs/advanced-usage/api-reference.md new file mode 100644 index 0000000..ac0fd0d --- /dev/null +++ b/docs/advanced-usage/api-reference.md @@ -0,0 +1,125 @@ +--- +title: API reference +sidebar_position: 20 +--- + +Every public class and method. Namespaces are under `Javaabu\Exports`. + +# ModelExport + +`abstract class ModelExport implements Contracts\Exportable`, using `Concerns\WritesFiles`. + +| Method | | +| --- | --- | +| `__construct(Builder\|Relation\|null $query = null)` | | +| `static make(mixed ...$arguments): static` | `new static(...$arguments)` | +| `abstract modelClass(): string` | The model class | +| `columns(): array` | `string\|Column` per column; defaults to `allowedAttributes()` | +| `allowedAttributes(): array` | The default columns | +| `relationsToInclude(): array` | Relations appended to the default columns | +| `query(): Builder\|Relation` | The query, with `eagerLoads()` added | +| `eagerLoads(): array` | Relations the columns need | +| `models(): LazyCollection` | The models, a chunk at a time | +| `rows(): iterable` | `map()` of every model | +| `totalRows(): ?int` | `count(*)` of the query | +| `chunkSize(): int` | `exports.chunk_size` | +| `map(Model $model): array` | One row | +| `formatValue(Model $model, string $attribute, mixed $value): mixed` | Formats a value | +| `getEnumLabel(Model $model, string $attribute, UnitEnum $value): string` | | +| `displayName(Model $model): mixed` | How a related model is shown | +| `isAdminModel(…)`, `isAdminModelCollection(…)` | v1 helpers | +| `headings(): array` | One per column | +| `resolvedColumns(): array` | The columns as `Column` instances | +| `filename(): string` | `users` | +| `label(): string` | `Users`, translated | +| `authorize(?Model $user): bool` | May the user run it (default: `viewAny` on the model) | +| `static fromRequest(Request $request): static` | Builds it for `POST /exports` | + +# WritesFiles + +`trait Concerns\WritesFiles`, for any `Exportable`. + +| Method | | +| --- | --- | +| `writeTo(string $path, Formats\|string\|null $format = null): int` | Rows written | +| `download(?string $filename = null, Formats\|string\|null $format = null): BinaryFileResponse` | | +| `store(?string $path = null, ?string $disk = null, Formats\|string\|null $format = null): string` | Path on the disk | +| `queue(?Model $user = null, Formats\|string\|null $format = null, ?string $filename = null, ?string $disk = null): Models\Export` | | +| `downloadName(?string $filename, Formats $format): string` | The file name with extension | + +# Other exports and contracts + +| Class | | +| --- | --- | +| `IterableExport::make(array $headings, iterable\|Closure $rows, string $filename = 'export', ?int $total_rows = null)` | Rows that aren't models | +| `Contracts\Exportable` | `headings()`, `rows()`, `totalRows()`, `filename()`, `label()` | +| `Contracts\HasExportHeadings` | `exportHeadings(): array` on a model | +| `Columns\Column` | `make()`, `label()`, `format()`, `value()`, `getLabel()`, `getFormatter()`, `getValueResolver()`, `relationPath()`, `attribute()` | + +# Exports + +`class Exports` (static). + +| Method | | +| --- | --- | +| `register(string\|array $type, ?string $export_class = null): void` | Lets the API start an export by name | +| `registered(): array` | `type => class` | +| `exportClass(string $type): ?string` | | +| `typeOf(Exportable\|string $export): ?string` | | +| `forget(?string $type = null): void` | | +| `exportModel(): string`, `newExportModel(): Export` | `exports.models.export` | +| `ignoreMigrations(): void` | | + +# Models\Export + +| Method | | +| --- | --- | +| `user(): MorphTo` | | +| `scopeOwnedBy(Model $user)`, `scopeStatus(ExportStatuses\|string $status)`, `scopeExpired(?DateTimeInterface $now = null)` | | +| `isOwnedBy(?Model $user): bool` | | +| `progress(): ?int` | 0–100 | +| `hasExpired()`, `isDownloadable()`, `isCancellable()`, `wasCancelled()`, `fileExists()`: `bool` | | +| `cancel(): bool` | | +| `markProcessing(?int $total_rows)`, `recordProgress(int $processed_rows)`, `markCompleted(string $disk, string $path, string $filename, int $rows, ?int $size)`, `markFailed(Throwable\|string $error)` | Used by the job | +| `download(): StreamedResponse` | | +| `deleteFile(): void` | | +| `temporaryDownloadUrl(?DateTimeInterface $expires_at = null): ?string` | | +| `broadcastChannel(): string`, `ownerBroadcastChannel(): ?string` | | +| `static expiresAfterDays(): int` | | + +# Enums + +| Enum | Cases | Methods | +| --- | --- | --- | +| `Enums\Formats` | `CSV`, `XLSX`, `ODS` | `extension()`, `mimeType()`, `label()`, `static resolve($format, $filename)`, `static enabled()` | +| `Enums\ExportStatuses` | `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED`, `CANCELLED` | `label()`, `isFinished()`, `isCancellable()` | + +# Jobs, events, notifications, commands + +| Class | | +| --- | --- | +| `Jobs\ProcessExport` | `__construct(Export $export, Exportable $exportable)`, `handle()`, `failed()`; `$tries = 1`, `$timeout`, `$locale` | +| `Events\ExportStarted`, `ExportProgressed`, `ExportCompleted`, `ExportFailed`, `ExportCancelled` | `public Export $export`; `ShouldBroadcastNow` with `broadcastWhen()` | +| `Notifications\ExportCompletedNotification`, `ExportFailedNotification` | `mail` and `database` (`toArray()`) | +| `Commands\PruneExportsCommand` | `exports:prune {--days=} {--pretend}` | +| `Exceptions\ExportCancelledException` | Thrown inside the job to stop it | + +# HTTP + +| Class | | +| --- | --- | +| `Routing\ExportRoutes` | `register(Router $router, array $options)`, `guard(Request)`, `namePrefix(Request)`; `Route::exports(array $options = [])` | +| `Http\Controllers\ExportsController` | `index`, `store`, `show`, `download`, `signedDownload`, `cancel`, `destroy` | +| `Http\Requests\ExportRequest` | `type`, `format`, `filename` | +| `Http\Resources\ExportResource` | | +| `Policies\ExportPolicy` | `viewAny`, `viewOthers`, `create`, `view`, `download`, `cancel`, `delete` | +| `Views\ViewRenderer` | `render(string $page, array $data)`, `stack()`, `isInertia()`, `inertiaPage(string $page)` | + +# Support + +| Class | | +| --- | --- | +| `Writers\SpreadsheetWriter` | `write(string $path, Formats $format, array $headings, iterable $rows): int` | +| `Support\SerializableQuery` | `static from(Builder\|Relation $query)`, `toBuilder(): Builder` | +| `Support\Broadcasting` | `enabled()`, `pollInterval()`, `channelPrefix()`, `registerChannels()`, `encodeType()`, `payload()` | +| `Support\TemporaryFile` | `path(Formats $format)`, `moveToDisk(…)`, `delete(string $path)` | diff --git a/docs/advanced-usage/configuration.md b/docs/advanced-usage/configuration.md new file mode 100644 index 0000000..b3f5acd --- /dev/null +++ b/docs/advanced-usage/configuration.md @@ -0,0 +1,81 @@ +--- +title: Configuration +sidebar_position: 10 +--- + +Publish the config file to change any option: + +```bash +php artisan vendor:publish --tag=exports-config +``` + +Everything has a default; most apps change nothing. + +# Files + +| Key | Default | | +| --- | --- | --- | +| `format` | `'xlsx'` | Format used when none is given and the file name has no extension | +| `formats` | `['xlsx', 'csv', 'ods']` | Formats users may choose on the exports page and API | +| `disk` | `env('EXPORTS_DISK')` | Disk for stored and queued exports; null means the default disk | +| `directory` | `'exports'` | Directory on the disk | +| `temporary_directory` | `null` | Where files are written before they're sent or moved; null means the system temp directory | +| `csv.delimiter` | `','` | | +| `csv.enclosure` | `'"'` | | +| `csv.bom` | `true` | Adds a UTF-8 BOM so Excel reads Thaana | +| `csv.escape_formulas` | `true` | Prefixes values starting with `=`, `+`, `-`, `@` with an apostrophe | +| `xlsx.auto_size` | `true` | Sizes columns to their widest value | +| `xlsx.max_column_width` | `60` | | +| `rtl` | `'auto'` | Right-to-left XLSX sheets: `true`, `false`, or `'auto'` for the `rtl_locales` | +| `rtl_locales` | `['dv', 'ar', 'fa', 'he', 'ur']` | | + +# Reading and formatting + +| Key | Default | | +| --- | --- | --- | +| `chunk_size` | `1000` | Models loaded at a time. See [Performance](performance.md). | +| `exclude` | `['password', 'remember_token', '*_token', '*_secret', 'two_factor_recovery_codes', 'api_key']` | Columns left out of the default columns (`Str::is()` patterns) | +| `timestamps` | `true` | Include `created_at`, `updated_at` and `deleted_at` in the default columns | +| `resolve_foreign_keys` | `true` | Replace `role_id` with the `role` relation when the model has it | +| `timezone` | `null` | Timezone dates are converted to; null means `app.timezone` | +| `datetime_format` | `'Y-m-d H:i:s'` | | +| `date_format` | `'Y-m-d'` | For attributes cast to `date` | +| `list_separator` | `', '` | Joins values of to-many relations | + +# Queued exports + +| Key | Default | | +| --- | --- | --- | +| `table` | `'exports'` | | +| `user_key_type` | `'integer'` | Key type of user models: `integer`, `uuid`, `ulid` or `string`. Set before migrating. | +| `models.export` | `Export::class` | Your own subclass of `Javaabu\Exports\Models\Export` | +| `queue.connection` | `env('EXPORTS_QUEUE_CONNECTION')` | Null means the default connection | +| `queue.queue` | `env('EXPORTS_QUEUE')` | Null means the default queue | +| `queue.timeout` | `3600` | Job timeout in seconds | +| `queue.progress_every` | `null` | Save and broadcast progress every this many rows; null means `chunk_size` | +| `queue.job` | `ProcessExport::class` | Your own subclass of the job | +| `expire_after_days` | `7` | Days a file can be downloaded before `exports:prune` deletes it | +| `signed_url_minutes` | `1440` | Lifetime of signed links in notifications (capped at the file's expiry) | +| `notifications.enabled` | `true` | | +| `notifications.channels` | `['mail']` | | +| `notifications.completed` | `ExportCompletedNotification::class` | Null to turn it off | +| `notifications.failed` | `ExportFailedNotification::class` | Null to turn it off | +| `routes.signed_download` | `'exports.signed-download'` | Name of the signed download route used in notifications | + +# Live progress + +| Key | Default | | +| --- | --- | --- | +| `broadcasting.enabled` | `env('EXPORTS_BROADCASTING', 'auto')` | `true`, `false`, or `'auto'` (when laravel/reverb is installed and broadcasting is on) | +| `broadcasting.channel` | `'exports'` | Channel name prefix | +| `broadcasting.guards` | `null` | Guards that authorize the channels | +| `broadcasting.poll_interval` | `3000` | Milliseconds between polls when not broadcasting | + +# Pages and authorization + +| Key | Default | | +| --- | --- | --- | +| `views` | `env('EXPORTS_VIEWS', 'blade')` | `blade` or `inertia` | +| `controllers.exports` | `ExportsController::class` | | +| `policy` | `ExportPolicy::class` | The default policy the package registers | +| `register_permissions` | `true` | Register `view_exports` and the rest with javaabu/permissions | diff --git a/docs/advanced-usage/live-progress.md b/docs/advanced-usage/live-progress.md new file mode 100644 index 0000000..8fbf240 --- /dev/null +++ b/docs/advanced-usage/live-progress.md @@ -0,0 +1,72 @@ +--- +title: Live progress +sidebar_position: 3 +--- + +The exports page updates as exports run. With [Laravel Reverb](https://reverb.laravel.com) installed, progress is broadcast; without it, the page polls. Nothing else changes. + +# Turning it on + +```bash +php artisan install:broadcasting # installs Reverb and Laravel Echo +``` + +With `exports.broadcasting.enabled` set to `auto` (the default), progress is broadcast when `laravel/reverb` is installed and the default broadcaster isn't `null` or `log`. Set it to `true` to broadcast with another broadcaster (Pusher, Ably), or `false` to always poll. + +# Channels + +| Channel | Who may listen | | +| --- | --- | --- | +| `private-exports.{uuid}` | Users who may `view` the export | One export | +| `private-exports.owner.{type}.{id}` | The owner only | Every export of a user; `{type}` is the morph type with `\` replaced by `-` | + +The package authorizes both. The guards that authenticate the channels are `exports.broadcasting.guards` (null: the defaults). Change the `exports` prefix with `exports.broadcasting.channel`. + +| Event | Broadcast as | +| --- | --- | +| `ExportStarted` | `export.started` | +| `ExportProgressed` | `export.progressed` | +| `ExportCompleted` | `export.completed` | +| `ExportFailed` | `export.failed` | +| `ExportCancelled` | `export.cancelled` | + +They're broadcast immediately (`ShouldBroadcastNow`), from the job, every `exports.queue.progress_every` rows. Each sends: + +```json +{ + "id": "9d3c…", + "status": "processing", + "status_label": "Processing", + "progress": 25, + "total_rows": 200000, + "processed_rows": 50000, + "is_finished": false, + "is_downloadable": false, + "error": null +} +``` + +# Listening in your own pages + +The API's `broadcast_channel` and `meta.broadcasting` tell a page what to do: + +```ts +import { useEcho } from '@laravel/echo-react'; + +useEcho(`exports.${exportId}`, ['.export.progressed', '.export.completed', '.export.failed', '.export.cancelled'], (payload) => { + setExport((current) => ({ ...current, ...payload })); +}); +``` + +```js +// Blade, with Laravel Echo +Echo.private(`exports.${exportId}`) + .listen('.export.progressed', (payload) => updateRow(payload)) + .listen('.export.completed', (payload) => updateRow(payload)); +``` + +Note the leading dot: the events use `broadcastAs()` names. + +# Polling + +When `meta.broadcasting` is `false`, poll `GET /exports/{export}` (with `Accept: application/json`) every `meta.poll_interval` milliseconds (`exports.broadcasting.poll_interval`, 3000) until `is_finished` is true. The package's pages do this themselves. diff --git a/docs/advanced-usage/performance.md b/docs/advanced-usage/performance.md new file mode 100644 index 0000000..3930ae9 --- /dev/null +++ b/docs/advanced-usage/performance.md @@ -0,0 +1,51 @@ +--- +title: Performance +sidebar_position: 1 +--- + +Exports stream: rows are read a chunk at a time and written as they're read, so memory depends on the chunk size, not on how many rows there are. + +# Benchmark + +`tests/Benchmark/MillionRowsTest.php` exports 1,000,000 users with an eager-loaded relation from SQLite (chunk size 1000). It runs in CI on every push. + +| Format | Time (CI) | Peak memory above the baseline | File | +| --- | --- | --- | --- | +| CSV | 157 s | +4.8 MB | 111 MB | +| XLSX | 190 s | +3.6 MB | 30 MB | + +Run it yourself with `composer benchmark`, and change the number of rows with `EXPORTS_BENCHMARK_ROWS`. + +# How rows are read + +`ModelExport::models()` reads with: + +- `lazyById(chunk_size)` when the query has no `orderBy()`. Each chunk is `where id > ? order by id limit 1000`, which stays fast deep into big tables. +- `lazy(chunk_size)` when it has one, so your order is kept. That uses `limit … offset …`, which gets slower on very large tables; order by an indexed column, or drop the order for huge exports. + +Relations used by the columns are eager loaded once per chunk. + +Tips: + +- Keep the primary key in any `select()`; `lazyById()` needs it. +- Raise `exports.chunk_size` (1000) for narrow rows, lower it for wide rows with many relations. +- Prefer `belongsTo` columns over to-many ones on huge exports: each to-many relation is another query per chunk and more memory per row. +- `totalRows()` runs one `count(*)` for progress. Override it to return `null` if counting is expensive; progress is then unknown until the end. + +# How rows are written + +[OpenSpout](https://github.com/openspout/openspout) writes each row to disk as soon as it's added. XLSX files use inline strings (no shared-strings table in memory). Column widths are tracked as the widest value per column, which costs one integer per column. + +Queued exports write the whole file in one job, because XLSX and ODS can't be appended to by parallel jobs. Give big exports a dedicated queue and a worker timeout above `exports.queue.timeout`: + +```bash +php artisan queue:work --queue=exports --timeout=3700 +``` + +```php +// config/exports.php +'queue' => [ + 'queue' => 'exports', + 'timeout' => 3600, +], +``` diff --git a/docs/advanced-usage/permissions-and-policies.md b/docs/advanced-usage/permissions-and-policies.md new file mode 100644 index 0000000..5875709 --- /dev/null +++ b/docs/advanced-usage/permissions-and-policies.md @@ -0,0 +1,100 @@ +--- +title: Permissions and policies +sidebar_position: 2 +--- + +# Permissions + +The package registers these permissions with [javaabu/permissions](https://github.com/Javaabu/permissions), in the `exports` group (labelled "Exports"), for every guard in `config('permissions.guards')`: + +| Permission | Description | Allows | +| --- | --- | --- | +| `view_exports` | View own exports | Listing, starting, following and downloading your own exports | +| `view_others_exports` | View other users' exports | The same for everyone's exports | +| `delete_exports` | Delete own exports | Cancelling and deleting your own exports | +| `delete_others_exports` | Delete other users' exports | The same for everyone's exports | + +Run `php artisan permissions:sync` after installing or updating the package. Give them to roles in `config/permissions.php` or from the roles page, for example: + +```php +// config/permissions.php +'roles' => [ + 'super_admin' => ['*'], + 'staff' => ['view_exports', 'delete_exports'], +], +``` + +To register your own permissions instead, set `exports.register_permissions` to `false` and register a policy for the Export model yourself. + +# The policy + +`Javaabu\Exports\Policies\ExportPolicy` extends javaabu/permissions' `ResourcePolicy`: + +| Ability | Needs | +| --- | --- | +| `viewAny`, `create` | `view_exports` or `view_others_exports` | +| `viewOthers` | `view_others_exports` | +| `view`, `download` | `view_others_exports`, or `view_exports` and owning the export | +| `cancel`, `delete` | `delete_others_exports`, or `delete_exports` and owning the export | +| `update`, `restore`, `forceDelete`, `viewTrash` | Never | + +Owning means the export's `user` morph is the user (`$export->isOwnedBy($user)`). + +Starting an export also needs the export's own `authorize($user)` to pass. For a `ModelExport` that's `viewAny` on its model. See [Exports page and API](../basic-usage/exports-page-and-api.md#letting-users-start-exports). + +# Overriding the policy + +The app wins over the package. Either: + +```php +// config/permissions.php +'policies' => [ + \Javaabu\Exports\Models\Export::class => \App\Policies\ExportPolicy::class, +], +``` + +or, in any service provider: + +```php +use Illuminate\Support\Facades\Gate; + +Gate::policy(\Javaabu\Exports\Models\Export::class, \App\Policies\ExportPolicy::class); +``` + +Extend the package policy to change one ability: + +```php +namespace App\Policies; + +use Illuminate\Database\Eloquent\Model; +use Javaabu\Exports\Policies\ExportPolicy as BaseExportPolicy; + +class ExportPolicy extends BaseExportPolicy +{ + public function cancel(Model $user, Model $export): bool + { + return $this->view($user, $export); + } +} +``` + +`exports.policy` sets the default policy the package registers; prefer the two options above in apps. + +# Multiple guards and user models + +Nothing assumes the `web` guard or `App\Models\User`: + +- Exports belong to any model through the `user` morph. `queue($admin)` and the routes record whoever is logged in on the route's guard. +- Register the routes once per guard, each with its own prefix and name. Each route group only answers to its guard's users. +- Permissions are synced for each guard in `config('permissions.guards')`, so `view_exports` on `web_admin` is separate from `view_exports` on `web`. +- A user with `view_others_exports` sees every export in the table, whichever guard started it. + +```php +Route::middleware('web')->group(function () { + Route::exports(); // users on the default guard + + Route::exports(['guard' => 'web_admin', 'prefix' => 'admin/exports', 'name' => 'admin.exports']); +}); +``` + +User models need javaabu/permissions' `HasRoles` trait, and `Notifiable` for notifications. If their keys aren't integers, set `exports.user_key_type` before migrating. diff --git a/docs/advanced-usage/translations.md b/docs/advanced-usage/translations.md new file mode 100644 index 0000000..a2016ab --- /dev/null +++ b/docs/advanced-usage/translations.md @@ -0,0 +1,35 @@ +--- +title: Dhivehi and translations +sidebar_position: 5 +--- + +Every string the package shows (statuses, formats, messages, notifications, permission descriptions, page text) goes through `__()` with an English key. The package ships `lang/en.json` and `lang/dv.json`; Laravel loads them automatically. + +To change a translation, add the same key to your app's `lang/dv.json`. App lines win over package lines. + +# Headings and values in files + +- Default headings are translated: add the English headline (`"Island": "ރަށް"`) to `lang/dv.json`, or use `validation.attributes`, or the model's `exportHeadings()`. See [Model exports](../basic-usage/model-exports.md#headings). +- `Yes` and `No` are `އާނ` and `ނޫން`. +- Files are written in the current locale. Queued exports remember the locale that was active when `queue()` was called, so a user browsing in Dhivehi gets Dhivehi headings even though the worker runs in English. + +# Thaana in files + +- CSV files start with a UTF-8 BOM so Excel shows Thaana instead of question marks. +- XLSX sheets are right-to-left when the locale is in `exports.rtl_locales` (or `exports.rtl` is `true`). +- File names may be in Thaana: `ބޭނުންތެރިން.xlsx`. Downloads send them with `filename*` (RFC 6266). + +# Strings for review + +These Dhivehi translations were written by the package authors and should be checked by a native speaker: + +| English | Dhivehi | Note | +| --- | --- | --- | +| Pending | މަޑުކުރަނީ | "Waiting"; an alternative is ފެށިފައެއް ނުވޭ | +| Processing | ތައްޔާރުކުރަނީ | "Being prepared" | +| Cancelled | ކެންސަލްކުރެވިއްޖެ | Loanword | +| Excel (XLSX) | އެކްސެލް (XLSX) | | +| OpenDocument (ODS) | އޯޕަން ޑޮކިއުމަންޓް (ODS) | | +| View other users' exports | އެހެން ފަރާތްތަކުގެ އެކްސްޕޯޓްތައް ބެލުން | "Other parties'" | +| Your :name export could not be completed… | ތިބާގެ :name އެކްސްޕޯޓް ނިންމޭކަށް ނުކެރުނު… | Tone of the apology | +| Your export has started. We will let you know when it is ready. | އެކްސްޕޯޓް ފެށިއްޖެ. ތައްޔާރުވުމުން އަންގާނަން. | | diff --git a/docs/ai-assistants.md b/docs/ai-assistants.md new file mode 100644 index 0000000..9b5a396 --- /dev/null +++ b/docs/ai-assistants.md @@ -0,0 +1,34 @@ +--- +title: AI assistants +sidebar_position: 1.35 +--- + +The docs are plain Markdown, written for people and AI coding assistants alike. The package ships them, so they always match the version you have installed. + +| File | What | +| --- | --- | +| `vendor/javaabu/exports/llms.txt` | The rules for using the package, and every page | +| `vendor/javaabu/exports/docs/` | The pages | +| `vendor/javaabu/exports/resources/boost/` | The Laravel Boost skill and guideline | + +# Laravel Boost + +[Laravel Boost](https://github.com/laravel/boost) finds the skill and guideline by itself in Composer packages. Run this after installing or updating the package: + +```bash +php artisan boost:update +``` + +Choose `javaabu/exports` if Boost asks which third-party packages to use. + +# Other assistants + +Point your assistant at the docs from the project's instructions file (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, `.github/copilot-instructions.md`): + +```md +## Exports + +Spreadsheet exports use javaabu/exports. Before writing an export, read vendor/javaabu/exports/llms.txt. +``` + +Or copy `vendor/javaabu/exports/resources/boost/skills/javaabu-exports` into your assistant's skills folder, such as `.claude/skills/`. diff --git a/docs/basic-usage/downloading-and-storing.md b/docs/basic-usage/downloading-and-storing.md new file mode 100644 index 0000000..ce37220 --- /dev/null +++ b/docs/basic-usage/downloading-and-storing.md @@ -0,0 +1,63 @@ +--- +title: Downloading and storing +sidebar_position: 2 +--- + +Every export can be downloaded, stored on a disk, written to a local path, or [queued](queued-exports.md). + +# Formats + +| Format | Extension | Notes | +| --- | --- | --- | +| `Formats::XLSX` | `.xlsx` | The default. Bold, frozen heading row; columns sized to fit; right-to-left in Dhivehi. | +| `Formats::CSV` | `.csv` | UTF-8 with a BOM, so Excel shows Thaana correctly. | +| `Formats::ODS` | `.ods` | OpenDocument, for LibreOffice. | + +The format comes from the `$format` argument (a `Javaabu\Exports\Enums\Formats` case or `'csv'`, `'xlsx'`, `'ods'`), then from the file name's extension, then from `exports.format`. + +# Downloading + +```php +use App\Exports\UsersExport; + +public function export(Request $request) +{ + $this->authorize('viewAny', User::class); + + return UsersExport::make()->download('users.xlsx'); +} +``` + +```php +download(?string $filename = null, Formats|string|null $format = null): BinaryFileResponse +``` + +The file is written to a temporary file first, so errors happen before any headers are sent, then sent and deleted. Without a file name you get `users-2026-11-04-093015.xlsx`; a name without an extension gets one. + +Downloads run during the request. For more than a few tens of thousands of rows, [queue](queued-exports.md) the export instead. + +# Storing on a disk + +```php +$path = UsersExport::make()->store('reports/users.csv', disk: 's3'); +``` + +```php +store(?string $path = null, ?string $disk = null, Formats|string|null $format = null): string +``` + +Returns the path on the disk. Without a path, the file goes in `exports.directory` with the generated name (`exports/users-2026-11-04-093015.xlsx`). Without a disk, `exports.disk` is used, then the app's default disk. The file is written locally and streamed to the disk, so it works with S3 and other remote disks. + +# Writing to a local path + +```php +$rows = UsersExport::make()->writeTo(storage_path('app/users.ods')); +``` + +Returns the number of data rows written. + +# Safe files + +- **No formulas in XLSX and ODS.** Strings are always written as text, so a name like `=HYPERLINK(…)` is shown, not run. +- **Escaped formulas in CSV.** A value starting with `=`, `+`, `-` or `@` (other than a number) gets an apostrophe in front, which spreadsheets hide. Turn it off with `exports.csv.escape_formulas` if a program, not a person, reads the files. +- **Temporary files** go in `exports.temporary_directory` (the system temp directory by default) and are always deleted. diff --git a/docs/basic-usage/exports-page-and-api.md b/docs/basic-usage/exports-page-and-api.md new file mode 100644 index 0000000..3e6941f --- /dev/null +++ b/docs/basic-usage/exports-page-and-api.md @@ -0,0 +1,177 @@ +--- +title: Exports page and API +sidebar_position: 4 +--- + +The package has routes where users start exports, follow their progress, and download, cancel and delete them. Every route answers JSON when the request expects it (`Accept: application/json`), and renders a page otherwise. + +# Registering the routes + +Routes aren't loaded automatically. Register them inside your own group: + +```php +// routes/web.php +use Illuminate\Support\Facades\Route; + +Route::middleware('web')->group(function () { + Route::exports(); +}); +``` + +For an admin panel on its own guard: + +```php +Route::middleware('web')->group(function () { + Route::exports([ + 'guard' => 'web_admin', + 'prefix' => 'admin/exports', + 'name' => 'admin.exports', + ]); +}); +``` + +| Option | Default | | +| --- | --- | --- | +| `guard` | `null` (the default guard) | Used for the default `auth:{guard}` middleware | +| `prefix` | `exports` | The URL prefix | +| `name` | `exports` | The route name prefix | +| `middleware` | `['auth']` or `["auth:{guard}"]` | Replaces the default middleware of the authenticated routes | +| `controller` | `config('exports.controllers.exports')` | See [Overriding controllers and views](../advanced-usage/overriding-controllers-and-views.md) | +| `signed` | `true` | Also register the signed download route | +| `signed_middleware` | `['signed']` | Middleware of the signed download route | + +| Method | URL | Name | Ability | +| --- | --- | --- | --- | +| `GET` | `/exports` | `exports.index` | `viewAny` | +| `POST` | `/exports` | `exports.store` | `create`, and the export's `authorize()` | +| `GET` | `/exports/{export}` | `exports.show` | `view` | +| `GET` | `/exports/{export}/download` | `exports.download` | `download` | +| `POST` | `/exports/{export}/cancel` | `exports.cancel` | `cancel` | +| `DELETE` | `/exports/{export}` | `exports.destroy` | `delete` | +| `GET` | `/exports/{export}/signed-download` | `exports.signed-download` | A valid signature (no login) | + +`{export}` is the export's UUID. Notifications link to `exports.signed-download`; if you register the routes under another name, set `exports.routes.signed_download` to match (for example `admin.exports.signed-download`). + +# Letting users start exports + +The page and `POST /exports` start exports by a registered name. Register the exports users may start, in a service provider's `boot()`: + +```php +use App\Exports\UsersExport; +use Javaabu\Exports\Exports; + +Exports::register('users', UsersExport::class); + +Exports::register([ + 'users' => UsersExport::class, + 'payments' => PaymentsExport::class, +]); +``` + +A user may start an export when they have `view_exports` (the `create` ability) and the export's `authorize()` allows it. For a `ModelExport`, that's the `viewAny` ability on its model, so users only export what they can list: + +```php +public function authorize(?Model $user): bool +{ + return $user?->can('export', User::class) ?? false; +} +``` + +To build the export from the request, for example with filters, override `fromRequest()`: + +```php +use Illuminate\Http\Request; + +public static function fromRequest(Request $request): static +{ + return new static(User::query()->where('status', $request->input('status'))); +} +``` + +Validate anything you read from the request: `fromRequest()` runs after the package validates `type`, `format` and `filename` only. + +The exports page lists only the registered exports the user may start. + +# The JSON API + +## List exports + +```http +GET /exports?status=processing&type=users&mine=1&per_page=20 +Accept: application/json +``` + +Users with `view_exports` see their own exports; users with `view_others_exports` see everyone's (`mine=1` narrows it to their own). Newest first, paginated. + +```json +{ + "data": [ + { + "id": "9d3c…", + "type": "users", + "name": "Users", + "format": "xlsx", + "format_label": "Excel (XLSX)", + "status": "processing", + "status_label": "Processing", + "progress": 25, + "total_rows": 200000, + "processed_rows": 50000, + "filename": "users-2026-11-04-093015.xlsx", + "size": null, + "is_finished": false, + "is_downloadable": false, + "is_cancellable": true, + "user": { "type": "user", "id": 1, "name": "އަހުމަދު" }, + "can": { "download": true, "cancel": true, "delete": true }, + "links": { + "show": "https://example.mv/exports/9d3c…", + "download": null, + "cancel": "https://example.mv/exports/9d3c…/cancel", + "destroy": "https://example.mv/exports/9d3c…" + }, + "broadcast_channel": "exports.9d3c…", + "started_at": "2026-11-04T09:30:15+05:00", + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "expires_at": null, + "created_at": "2026-11-04T09:30:15+05:00", + "updated_at": "2026-11-04T09:31:02+05:00" + } + ], + "links": { "…": "…" }, + "meta": { + "current_page": 1, + "broadcasting": true, + "poll_interval": 3000, + "routes": { "index": "exports.index", "store": "exports.store", "show": "exports.show", "download": "exports.download", "cancel": "exports.cancel", "destroy": "exports.destroy" } + } +} +``` + +`error` is included for failed exports. `broadcast_channel` is null when [live progress](../advanced-usage/live-progress.md) is off; poll `GET /exports/{export}` every `meta.poll_interval` milliseconds instead. + +## Start an export + +```http +POST /exports +Content-Type: application/json + +{ "type": "users", "format": "csv", "filename": "ބޭނުންތެރިން" } +``` + +`201` with the export. `format` (one of `exports.formats`) and `filename` are optional; slashes and other characters file systems reject are replaced in the file name. `422` for an unknown type or format, `403` when the user may not run it. + +## Follow, download, cancel, delete + +| Request | Response | +| --- | --- | +| `GET /exports/{export}` | The export, with `meta` as above | +| `GET /exports/{export}/download` | The file; `404` until it's completed, `410` once it has expired | +| `POST /exports/{export}/cancel` | The export; `409` if it already finished | +| `DELETE /exports/{export}` | `204`; the file is deleted too | + +# Pages + +Without `Accept: application/json`, `index` and `show` render a page, and `store`, `cancel` and `destroy` redirect back with a `success` or `error` flash message. Which stack renders them is set by `exports.views`: `blade` (default) or `inertia`. See [Overriding controllers and views](../advanced-usage/overriding-controllers-and-views.md) for the data each page gets. diff --git a/docs/basic-usage/model-export.md b/docs/basic-usage/model-export.md deleted file mode 100644 index ceaa30b..0000000 --- a/docs/basic-usage/model-export.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -title: ModelExport ---- - -## Basic Usage - -You can extend the `ModelExport` class to easily create exports for a model. It will automatically include all non-hidden database columns of the model. The headings will be converted to title case. - -```php -description; - } - - return parent::formatValue($model, $attribute, $value); - } -} -``` - -## Modifying the headings - -To modify the headings, override the `headings` method. Remember to include all the headings in the correct order and to include a heading for each allowed attribute. - -```php -download('users.xlsx'); +``` + +Pass a query to export some of the models. `make()` passes its arguments to the constructor: + +```php +UsersExport::make(User::query()->where('is_active', true)->orderBy('name'))->download(); + +UsersExport::make($organization->users())->download(); // relations work too +``` + +# The default columns + +Without any configuration, an export includes the model's table columns, in table order, except: + +- the model's `$hidden` attributes +- the names in `exports.exclude`: `password`, `remember_token`, `*_token`, `*_secret`, `two_factor_recovery_codes`, `api_key` (patterns allowed) +- `created_at`, `updated_at` and `deleted_at` when `exports.timestamps` is `false` + +A foreign key is replaced by its relation when the model defines a `BelongsTo` with the same name: `role_id` becomes `role`, and the column shows the role's name. Turn this off with `exports.resolve_foreign_keys`. + +For a `users` table with `id`, `name`, `email`, `password`, `role_id`, `status`, `created_at` and `updated_at`, and a `role()` relation, the file looks like this: + +| ID | Name | Email | Role | Status | Created At | Updated At | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | އަހުމަދު | ahmed@example.mv | Editor | Approved | 2026-11-04 09:30:00 | 2026-11-04 09:30:00 | + +# Choosing columns + +Override `columns()` to pick the columns and their order. A column is an attribute, a relation, or a relation's attribute in dot notation: + +```php +use Javaabu\Exports\Columns\Column; + +class UsersExport extends ModelExport +{ + public function modelClass(): string + { + return User::class; + } + + public function columns(): array + { + return [ + 'name', + 'email', + 'status', // an enum: its label + 'role', // a relation: the related model's name + 'role.description', // a relation's attribute + 'organizations.name', // a to-many relation: "Javaabu, Gov.mv" + Column::make('national_id')->label(__('National ID')), + Column::make('created_at')->format(fn ($value) => $value->toDateString()), + Column::make('address')->value(fn (User $user) => "{$user->island}, {$user->atoll}"), + ]; + } +} +``` + +Relations used by the columns are eager loaded for every chunk, so there are no N+1 queries. Relations already eager loaded on a query you pass in, with or without constraints, are kept. + +## Column options + +| Method | Does | +| --- | --- | +| `Column::make(string $name)` | A column for an attribute, relation or dot-notation path | +| `->label(string\|Closure $label)` | The heading | +| `->format(Closure $callback)` | Formats the value yourself: `fn (mixed $value, Model $model) => …`. The export's `formatValue()` isn't called. | +| `->value(Closure $callback)` | Computes the value from the model: `fn (Model $model) => …`. Nothing is eager loaded for it. | + +## Adding relations to the default columns + +To keep the default columns and add relations at the end, list them in `relationsToInclude()`. Their foreign keys are left out: + +```php +public function relationsToInclude(): array +{ + return ['role', 'organizations']; +} +``` + +`allowedAttributes()` returns the default columns. Override it, or `columns()`, to change them. + +# Headings + +Each column's heading is the first of: + +1. the column's `label()` +2. the model's `exportHeadings()`, if it implements `Javaabu\Exports\Contracts\HasExportHeadings` +3. the validation attribute name `validation.attributes.{column}` from `lang/{locale}/validation.php` (dots become underscores) +4. a headline of the name, translated: `role_id` → `Role ID`, `role.name` → `Role`, `role.description` → `Role Description` + +```php +use Javaabu\Exports\Contracts\HasExportHeadings; + +class User extends Authenticatable implements HasExportHeadings +{ + public function exportHeadings(): array + { + return [ + 'nid' => __('National ID'), + 'role.name' => __('Role'), + ]; + } +} +``` + +Because headlines go through `__()`, add them to `lang/dv.json` to get Dhivehi headings. The package translates `ID`, `Name`, `Email`, `Status`, `Created At`, `Updated At` and `Deleted At` already. + +To set every heading at once, override `headings()` and return one heading per column. + +# Values + +`formatValue()` turns each value into something a spreadsheet shows well: + +| Value | Written as | +| --- | --- | +| An enum | `getLabel()` or `label()` if the enum has one, otherwise a headline of its value: `pending_review` → `Pending Review` | +| A boolean | `Yes` or `No`, translated (`އާނ` / `ނޫން` in Dhivehi) | +| A date | In `exports.timezone` (the app timezone by default), as `exports.datetime_format`, or `exports.date_format` for attributes cast to `date` | +| An array or JSON cast | JSON, with Thaana unescaped | +| A related model | Its `admin_link_name`, `name`, `title` or `label`, or its key | +| A collection | Each item formatted, joined with `exports.list_separator` (`, `) | +| Anything else | As it is | + +Override `formatValue()` for one attribute and call the parent for the rest: + +```php +use Illuminate\Database\Eloquent\Model; + +public function formatValue(Model $model, string $attribute, mixed $value): mixed +{ + if ($attribute === 'salary') { + return number_format($value / 100, 2); + } + + return parent::formatValue($model, $attribute, $value); +} +``` + +Override `getEnumLabel()` to change how every enum is shown, or `displayName()` for related models. + +# The query + +`query()` returns the query passed to `make()`, or `Model::query()`, with the columns' relations eager loaded. Override it to add scopes for every use of the export: + +```php +public function query(): Builder|Relation +{ + return parent::query()->where('is_active', true); +} +``` + +Exports read with `lazyById()`, or `lazy()` when the query has an `orderBy()`. Keep the primary key in any `select()`. See [Performance](../advanced-usage/performance.md). + +# Names + +| Method | Default | Used for | +| --- | --- | --- | +| `filename()` | `users` for `User` | Download and stored file names: `users-2026-11-04-093015.xlsx` | +| `label()` | `Users`, translated | The exports page and notifications | + +# Exports that aren't models + +`IterableExport` writes any rows: an array, a generator, a `LazyCollection`, or a closure that returns one. + +```php +use Javaabu\Exports\IterableExport; + +return IterableExport::make( + ['ރަށް', 'އަތޮޅު', 'އާބާދީ'], + fn () => Island::query()->toBase()->lazyById()->map(fn ($island) => [$island->name, $island->atoll, $island->population]), + 'islands', +)->download(); +``` + +Anything that implements `Javaabu\Exports\Contracts\Exportable` (`headings()`, `rows()`, `totalRows()`, `filename()`, `label()`) and uses the `Javaabu\Exports\Concerns\WritesFiles` trait can be downloaded, stored and queued the same way. diff --git a/docs/basic-usage/queued-exports.md b/docs/basic-usage/queued-exports.md new file mode 100644 index 0000000..343ddd1 --- /dev/null +++ b/docs/basic-usage/queued-exports.md @@ -0,0 +1,148 @@ +--- +title: Queued exports +sidebar_position: 3 +--- + +Big exports run on the queue. The user gets a notification with a download link when the file is ready, and can follow progress on the [exports page](exports-page-and-api.md). + +# Queueing + +```php +use App\Exports\UsersExport; + +public function store(Request $request) +{ + $this->authorize('viewAny', User::class); + + $export = UsersExport::make(User::query()->where('is_active', true)) + ->queue($request->user(), format: 'xlsx', filename: 'active-users'); + + return redirect()->route('exports.show', $export); +} +``` + +```php +queue(?Model $user = null, Formats|string|null $format = null, ?string $filename = null, ?string $disk = null): Export +``` + +`queue()` creates a pending `Javaabu\Exports\Models\Export` and dispatches `Javaabu\Exports\Jobs\ProcessExport` on `exports.queue.connection` and `exports.queue.queue`. The user can be any model, from any guard. The export is serialized onto the queue: a query you passed in is kept as SQL, bindings, order and eager loads (closures included), and models held by the export become identifiers. Keep the rest of its state small: ids and filters, not loaded collections. The file is written in the locale that was active when `queue()` was called. + +# What the job does + +1. Skips the export if it was cancelled before the job ran. +2. Marks it `processing` and counts the rows (`totalRows()`). +3. Streams the rows to a local file, saving `processed_rows` every `exports.queue.progress_every` rows (the chunk size by default) and checking whether it was cancelled. +4. Moves the file to `exports/{uuid}/{filename}` on the export's disk. +5. Marks it `completed`, with `expires_at` set `exports.expire_after_days` days ahead. +6. Notifies the user. + +One job writes the whole file: XLSX and ODS files can't be appended to by parallel jobs, and reading in chunks already keeps memory flat. It runs once (`$tries = 1`) with a timeout of `exports.queue.timeout` seconds (3600). If it fails, the export is marked `failed` with the error message, and the user is notified. + +To change the job, extend `ProcessExport` and set `exports.queue.job`. + +# The Export model + +| Attribute | | +| --- | --- | +| `uuid` | Used in URLs (`getRouteKeyName()`) | +| `user` | Who started it (morph: `user_type`, `user_id`) | +| `type` | Its name in `Exports::register()`, if registered | +| `export_class`, `name` | The export class and its `label()` | +| `format` | A `Formats` case | +| `status` | An `ExportStatuses` case: `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED`, `CANCELLED` | +| `total_rows`, `processed_rows` | Progress | +| `disk`, `path`, `filename`, `size` | The file | +| `error` | Why it failed | +| `started_at`, `completed_at`, `failed_at`, `cancelled_at`, `expires_at` | | + +```php +use Javaabu\Exports\Models\Export; + +$export->progress(); // 0–100, or null while the row count is unknown +$export->isDownloadable(); // completed, stored and not expired +$export->download(); // a streamed download response +$export->temporaryDownloadUrl(); // a signed link, or null +$export->cancel(); // false if it already finished +$export->isOwnedBy($user); + +Export::query()->ownedBy($user)->status(ExportStatuses::COMPLETED)->latest()->get(); +``` + +Deleting an export deletes its file. To use your own model, extend `Export` and set `exports.models.export`; to rename the table, set `exports.table`. + +# Cancelling + +```php +$export->cancel(); +``` + +A pending export never starts. A processing one stops at its next progress check, and its temporary file is deleted. Users cancel from the exports page or `POST /exports/{export}/cancel`. + +# Notifications + +When the export completes, the user gets `ExportCompletedNotification`, with a signed link to download the file without logging in. When it fails, they get `ExportFailedNotification`. The user model needs Laravel's `Notifiable` trait; otherwise nothing is sent. + +```php +// config/exports.php +'notifications' => [ + 'enabled' => true, + 'channels' => ['mail'], // e.g. ['mail', 'database'] + 'completed' => \Javaabu\Exports\Notifications\ExportCompletedNotification::class, + 'failed' => \Javaabu\Exports\Notifications\ExportFailedNotification::class, // null to turn off +], +``` + +The signed link uses the `exports.signed-download` route, which [`Route::exports()`](exports-page-and-api.md) registers. It lasts `exports.signed_url_minutes` (a day), or until the file expires if that's sooner. If you name the routes differently, set `exports.routes.signed_download`; if the route doesn't exist, the email has no button. Notifications use the user's preferred locale, so Dhivehi users get Dhivehi emails. + +# Events + +| Event | When | +| --- | --- | +| `ExportStarted` | The job started | +| `ExportProgressed` | Another `progress_every` rows were written | +| `ExportCompleted` | The file is on the disk | +| `ExportFailed` | The job failed | +| `ExportCancelled` | `cancel()` was called | + +They're in `Javaabu\Exports\Events` and each has a public `$export`. They're also broadcast when [live progress](../advanced-usage/live-progress.md) is on. + +# Expiry and pruning + +Files expire `exports.expire_after_days` (7) days after they complete. Expired files can't be downloaded. Schedule the prune command to delete them: + +```php +use Illuminate\Support\Facades\Schedule; + +Schedule::command('exports:prune')->daily(); +``` + +```bash +php artisan exports:prune # expired exports, and unfinished ones older than the expiry period +php artisan exports:prune --days=2 # also everything created more than 2 days ago +php artisan exports:prune --pretend # list them without deleting +``` + +# Testing + +Fake the queue to assert an export was queued, or let the `sync` queue run it: + +```php +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; +use Javaabu\Exports\Jobs\ProcessExport; + +Queue::fake(); + +$this->actingAs($user)->post('/users/export'); + +Queue::assertPushed(ProcessExport::class); +``` + +```php +Storage::fake('local'); + +$export = UsersExport::make()->queue($user)->fresh(); + +$this->assertTrue($export->isDownloadable()); +Storage::disk('local')->assertExists($export->path); +``` diff --git a/docs/installation-and-setup.md b/docs/installation-and-setup.md index 28e2971..b69fd78 100644 --- a/docs/installation-and-setup.md +++ b/docs/installation-and-setup.md @@ -3,22 +3,103 @@ title: Installation & Setup sidebar_position: 1.2 --- -You can install the package via composer: +# Installing ```bash composer require javaabu/exports ``` -# Publishing the config file +The service provider is discovered automatically. Nothing else is needed to [download](basic-usage/downloading-and-storing.md) or [store](basic-usage/downloading-and-storing.md) exports. -Publishing the config file is optional: +# Migrating + +[Queued exports](basic-usage/queued-exports.md) are tracked in an `exports` table. The package loads its migration itself: + +```bash +php artisan migrate +``` + +Exports belong to whoever started them through a `user` morph, so any user model works. If your user models have UUID, ULID or string keys, set `user_key_type` before migrating: ```bash -php artisan vendor:publish --provider="Javaabu\Exports\ExportsServiceProvider" --tag="exports-config" +php artisan vendor:publish --tag=exports-config ``` -This is the default content of the config file: +```php +// config/exports.php +'user_key_type' => 'uuid', // integer (default), uuid, ulid or string +``` + +To change the migration itself, publish it and stop the package from loading its copy: + +```bash +php artisan vendor:publish --tag=exports-migrations +``` ```php -// TODO +// AppServiceProvider::register() +\Javaabu\Exports\Exports::ignoreMigrations(); +``` + +# Permissions + +The package registers four permissions with [javaabu/permissions](https://github.com/Javaabu/permissions). Write them to the database, as on every deploy: + +```bash +php artisan permissions:sync ``` + +| Permission | Allows | +| --- | --- | +| `view_exports` | Seeing, starting and downloading your own exports | +| `view_others_exports` | Seeing and downloading everyone's exports | +| `delete_exports` | Cancelling and deleting your own exports | +| `delete_others_exports` | Cancelling and deleting everyone's exports | + +See [Permissions and policies](advanced-usage/permissions-and-policies.md). + +# The exports page + +Register the routes inside your own route group, so you choose the prefix, middleware and guard: + +```php +// routes/web.php +use Illuminate\Support\Facades\Route; + +Route::middleware('web')->group(function () { + Route::exports(); +}); +``` + +Then register the exports users may start from that page: + +```php +// AppServiceProvider::boot() +use App\Exports\UsersExport; +use Javaabu\Exports\Exports; + +Exports::register('users', UsersExport::class); +``` + +See [Exports page and API](basic-usage/exports-page-and-api.md). + +# Cleaning up + +Files expire after 7 days (`exports.expire_after_days`). Schedule the prune command to delete them: + +```php +// routes/console.php +use Illuminate\Support\Facades\Schedule; + +Schedule::command('exports:prune')->daily(); +``` + +# Publishing + +| Tag | What | +| --- | --- | +| `exports-config` | `config/exports.php` | +| `exports-migrations` | The `exports` table migration | +| `exports-views` | The Blade views, to `resources/views/vendor/exports` | + +Every option is described in [Configuration](advanced-usage/configuration.md). diff --git a/docs/intro.md b/docs/intro.md index 2cb4749..8067803 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -5,5 +5,32 @@ sidebar_position: 1.0 # Exports +[javaabu/exports](https://github.com/Javaabu/exports) exports Eloquent models to CSV, XLSX and ODS files. -[Exports](https://github.com/Javaabu/exports) Handles Laravel exports using [maatwebsite/excel](https://docs.laravel-excel.com/). +- **Smart defaults.** Point an export at a model and you get its table columns, minus hidden attributes, passwords and tokens, with foreign keys replaced by the related model's name, readable (and translatable) headings, enum labels, "Yes"/"No" for booleans and dates in the app timezone. +- **Streaming.** Rows are read a chunk at a time and written with [OpenSpout](https://github.com/openspout/openspout), so memory stays flat. A million rows export with a few megabytes of memory. +- **Background exports.** Large exports run on the queue, write to any filesystem disk, report progress, can be cancelled, and notify the user with a signed, expiring download link. +- **An exports page and API.** Routes, an overridable controller, a JSON API and Blade or Inertia React pages where users follow progress live (over Laravel Reverb when it's installed, polling otherwise) and download their files. +- **Permissions.** A policy and default permissions through [javaabu/permissions](https://github.com/Javaabu/permissions), for any guard and user model. +- **Dhivehi.** Every string is translated, and XLSX sheets are right-to-left in Dhivehi. + +```php +use App\Models\User; +use Javaabu\Exports\ModelExport; + +class UsersExport extends ModelExport +{ + public function modelClass(): string + { + return User::class; + } +} + +// In a controller +return UsersExport::make()->download('users.xlsx'); + +// Or on the queue, for big exports +UsersExport::make(User::query()->where('is_active', true))->queue($request->user()); +``` + +Start with [Installation & Setup](installation-and-setup.md), then [Model exports](basic-usage/model-exports.md). Coming from v1? Read [Upgrading](upgrading.md). diff --git a/docs/metadata.json b/docs/metadata.json index 881dd21..0dd6aa4 100644 --- a/docs/metadata.json +++ b/docs/metadata.json @@ -1,5 +1,5 @@ { "name": "Exports", - "description": "Handles Laravel exports using maatwebsite/excel", - "version": "1" + "description": "Streaming model exports for Laravel: smart defaults, CSV, XLSX and ODS, background jobs and an exports UI with live progress", + "version": "2" } diff --git a/docs/requirements.md b/docs/requirements.md index 73bd6c0..23ac10a 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -3,7 +3,15 @@ title: Requirements sidebar_position: 1.1 --- -This package requires the following: +- PHP 8.4 or 8.5, with the `zip`, `xmlreader` and `dom` extensions (for XLSX and ODS) +- Laravel 12 or 13 +- [javaabu/permissions](https://github.com/Javaabu/permissions) v3, installed with this package -- Laravel 9.0 or higher -- PHP 8.1 or higher +Optional: + +| Package | For | +| --- | --- | +| `laravel/reverb` ^1.12 | Live progress on the exports page. Without it, the page polls. See [Live progress](advanced-usage/live-progress.md). | +| `inertiajs/inertia-laravel` ^2.0 | The Inertia React pages (`exports.views` = `inertia`). See [Exports page and API](basic-usage/exports-page-and-api.md). | + +A queue worker is needed for [queued exports](basic-usage/queued-exports.md). With the `sync` queue they run during the request. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..7e096b0 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,98 @@ +--- +title: Upgrading from v1 +sidebar_position: 1.25 +--- + +v2 replaces maatwebsite/excel with OpenSpout, streams every export, picks smarter defaults, and adds queued exports with an exports page. Most `ModelExport` classes keep working unchanged; their files look a little different. + +# Requirements + +PHP 8.4 or 8.5, and Laravel 12 or 13. + +```bash +composer require javaabu/exports:^2.0 +php artisan migrate +php artisan permissions:sync +``` + +v2 requires [javaabu/permissions](https://github.com/Javaabu/permissions) v3, for its policy and permissions. + +# maatwebsite/excel is no longer used + +**Why:** maatwebsite/excel builds sheets with PhpSpreadsheet, which keeps the whole workbook in memory and, for queued exports, re-opens the file for every chunk. Exports with hundreds of thousands of rows run out of memory or take very long. PhpSpreadsheet 1.x also can't be installed on PHP 8.5. v2 writes with [OpenSpout](https://github.com/openspout/openspout), which streams rows to disk: a million rows take about 4 MB of memory (see [Performance](advanced-usage/performance.md)). + +**What it means for you:** + +- `ModelExport` no longer implements maatwebsite's `FromQuery`, `WithHeadings`, `WithMapping` and `ShouldAutoSize`, and doesn't use its `Exportable` trait. Its own `download()`, `store()` and `queue()` replace them. +- `Excel::download(new UsersExport, 'users.xlsx')` becomes `UsersExport::make()->download('users.xlsx')`. +- `Excel::store(new UsersExport, 'users.xlsx', 's3')` becomes `UsersExport::make()->store('users.xlsx', 's3')`. +- maatwebsite's `->queue('users.xlsx')` becomes `->queue($user, filename: 'users')`, which returns an `Export` record. See [Queued exports](basic-usage/queued-exports.md). +- maatwebsite concerns such as `WithStyles`, `WithColumnFormatting`, `WithEvents`, `WithDrawings` and `WithMultipleSheets` aren't supported. If you rely on them for some exports, require maatwebsite/excel yourself (on PHP 8.4) and keep those classes on it; they don't need this package. +- Composer no longer installs maatwebsite/excel or phpoffice/phpspreadsheet for you. If other code uses them, require them directly. + +# Changes to ModelExport + +## Still works + +`modelClass()`, `relationsToInclude()`, `allowedAttributes()`, `query()`, `formatValue()`, `getEnumLabel()`, `isAdminModel()`, `isAdminModelCollection()`, `headings()` and `map()` can still be overridden, and the constructor still takes a query. + +## Different defaults + +| v1 | v2 | To keep v1 | +| --- | --- | --- | +| Every non-hidden column | Also leaves out `*_token`, `*_secret`, `remember_token`, `two_factor_recovery_codes`, `api_key` | Set `exports.exclude` to `[]` | +| `role_id` and `role` both included | `role_id` is left out when `relationsToInclude()` has its `BelongsTo`, and replaced by `role` when the model has a `role()` relation | Set `exports.resolve_foreign_keys` to `false`, or override `allowedAttributes()` | +| Headings `Id`, `Role Id`, `User Role` | `ID`, `Role ID`, `User Role`; from validation attribute names and the model when they exist; translated | Override `headings()` | +| Booleans `True` / `False` | `Yes` / `No`, translated | Override `formatValue()` | +| Enums: `getLabel()` or the case name | `getLabel()`, `label()`, or a headline of the value (`pending_review` → `Pending Review`) | Override `getEnumLabel()` | +| Dates as Carbon objects | Strings in the app timezone, `Y-m-d H:i:s` (`Y-m-d` for `date` casts) | Set `exports.datetime_format` | +| Related models joined with `,` | Joined with `, ` | Set `exports.list_separator` to `','` | +| Related models without `admin_link_name` serialized | Shown by `name`, `title`, `label` or their key | Override `displayName()` | +| XLSX columns auto-sized by PhpSpreadsheet | Sized from the widest value | — | + +## Type hints + +`map(Model $model)` and `formatValue(Model $model, …)` are typed now. Overrides without types (`map($model)`) still work, because PHP allows a child to widen a parameter. + +## New + +- `columns()` with dot notation and `Column` definitions. See [Model exports](basic-usage/model-exports.md#choosing-columns). +- `label()`, `filename()`, `authorize()`, `fromRequest()`, `chunkSize()`, `totalRows()`. +- `IterableExport` for rows that aren't models. + +# A typical v1 export, in v2 + +```php +// v1 +class UsersExport extends ModelExport +{ + public function modelClass(): string + { + return User::class; + } + + public function relationsToInclude(): array + { + return ['roles']; + } +} + +return Excel::download(new UsersExport($query), 'users.xlsx'); +``` + +```php +// v2: the class is unchanged +return UsersExport::make($query)->download('users.xlsx'); +``` + +Or, for big tables: + +```php +UsersExport::make($query)->queue($request->user(), 'xlsx'); + +return redirect()->route('exports.index'); +``` + +# Optional: the exports page + +Register the routes and your exports to give users a page to start, follow and download exports. See [Exports page and API](basic-usage/exports-page-and-api.md). diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..2601094 --- /dev/null +++ b/llms.txt @@ -0,0 +1,42 @@ +# javaabu/exports + +> Streaming model exports for Laravel. Extend ModelExport (or use IterableExport) and download, store or queue CSV, XLSX and ODS files with flat memory. Defaults leave out hidden attributes and secrets, replace foreign keys with related names, and produce readable, translated headings and values. Queued exports are tracked by an Export model with progress, cancellation, notifications with signed links, and pruning. Route::exports() adds an exports page and JSON API (Blade or Inertia React) with live progress over Reverb or polling, a policy and permissions through javaabu/permissions. + +Paths are relative to this file. In a project, that's vendor/javaabu/exports/. + +## Rules + +- Write exports as classes extending `Javaabu\Exports\ModelExport` with `modelClass()`; call them with `UsersExport::make($query)->download()`, `->store()`, `->writeTo()` or `->queue($user)`. Don't use maatwebsite/excel's `Excel::download()` with them: v2 doesn't use maatwebsite. +- Choose columns in `columns()`: attribute names, relation names, dot notation (`role.name`, `organizations.name`) or `Javaabu\Exports\Columns\Column::make()` with `label()`, `format()` or `value()`. Don't eager load or map by hand; the export eager loads what the columns need. +- Customise one value by overriding `formatValue(Model $model, string $attribute, mixed $value)` and calling `parent::formatValue()` for the rest. +- Use `queue()` for anything that can be more than a few tens of thousands of rows; `download()` runs during the request. +- Keep an export's constructor state serializable and small (a query builder, ids, filters). Builders are serialized as SQL; models as identifiers. Don't pass loaded collections or closures other than eager-load constraints. +- Keep the primary key in any `select()`: exports read with `lazyById()`. An `orderBy()` switches to `lazy()` (offset paging). +- Register exports users may start from the exports page with `Javaabu\Exports\Exports::register('users', UsersExport::class)` in a service provider's `boot()`. Who may run one is the export's `authorize($user)` (default: `viewAny` on the model). +- Register the routes yourself with `Route::exports([...])` inside the app's route group; pass `guard`, `prefix` and `name` for other guards. They aren't loaded automatically. +- Permissions are `view_exports`, `view_others_exports`, `delete_exports`, `delete_others_exports`. Run `php artisan permissions:sync`. Override the policy with `config('permissions.policies')` or `Gate::policy()`, never by editing the package. +- Schedule `exports:prune` daily. +- Headings and values go through `__()`; add Dhivehi to `lang/dv.json`. Use Thaana in tests of user-facing exports. + +## Docs + +- [Introduction](docs/intro.md): what the package does +- [Requirements](docs/requirements.md): PHP, Laravel, extensions and optional packages +- [Installation & Setup](docs/installation-and-setup.md): installing, migrating, permissions, routes, pruning, publishing +- [Upgrading from v1](docs/upgrading.md): why maatwebsite/excel was dropped, changed defaults, migrating exports +- [Model exports](docs/basic-usage/model-exports.md): default columns, columns(), Column, headings, values, the query, IterableExport +- [Downloading and storing](docs/basic-usage/downloading-and-storing.md): formats, download(), store(), writeTo(), formula escaping +- [Queued exports](docs/basic-usage/queued-exports.md): queue(), the job, the Export model, cancelling, notifications, events, pruning, testing +- [Exports page and API](docs/basic-usage/exports-page-and-api.md): Route::exports(), Exports::register(), the JSON API, pages +- [Performance](docs/advanced-usage/performance.md): the 1M-row benchmark, chunked reading, tips +- [Permissions and policies](docs/advanced-usage/permissions-and-policies.md): permissions, ExportPolicy, overriding it, multiple guards +- [Live progress](docs/advanced-usage/live-progress.md): Reverb, channels, events, listening, polling +- [Overriding controllers and views](docs/advanced-usage/overriding-controllers-and-views.md): custom controllers, Blade and Inertia pages and their props +- [Dhivehi and translations](docs/advanced-usage/translations.md): translated strings, Thaana in files, strings to review +- [Configuration](docs/advanced-usage/configuration.md): every option in config/exports.php +- [API reference](docs/advanced-usage/api-reference.md): every public class and method +- [AI assistants](docs/ai-assistants.md): Laravel Boost, and using these docs from other assistants +- [Questions and Security](docs/questions-and-security.md): where to get help +- [Changelog](docs/changelog.md): where changes are listed +- [About Us](docs/about-us.md): Javaabu +- [Contributing to the docs](docs/contributing-to-docs.md): how the docs are built diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..82a5601 --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -0,0 +1,10 @@ +{{-- + javaabu/exports guidelines for AI code assistants + + Maintained by: Javaabu (https://javaabu.com) +--}} + +## javaabu/exports + +- This project exports models to CSV, XLSX and ODS with `javaabu/exports`. Write exports as classes extending `Javaabu\Exports\ModelExport` and call `download()`, `store()` or `queue($user)` on them; don't use maatwebsite/excel for them. +- Always activate the `javaabu-exports` skill when writing or changing an export, adding an export button or the exports page, queueing an export, or changing who may export. diff --git a/resources/boost/skills/javaabu-exports/SKILL.md b/resources/boost/skills/javaabu-exports/SKILL.md new file mode 100644 index 0000000..30a24ee --- /dev/null +++ b/resources/boost/skills/javaabu-exports/SKILL.md @@ -0,0 +1,90 @@ +--- +name: javaabu-exports +description: Export Eloquent models or other rows to CSV, XLSX or ODS with javaabu/exports in a Laravel app. Use whenever the project depends on javaabu/exports and the task writes or changes an export class, chooses export columns or headings, adds an export or download button, exports a large table, queues an export or shows its progress, sets up the exports page or API, or changes who may export or download. +license: MIT +metadata: + author: Javaabu +--- + +# Exports with javaabu/exports + +Exports stream: rows are read a chunk at a time and written with OpenSpout, so memory stays flat for millions of rows. The docs ship with the package: `vendor/javaabu/exports/llms.txt` lists every page in `vendor/javaabu/exports/docs/`. + +## Writing an export + +```php +namespace App\Exports; + +use App\Models\User; +use Javaabu\Exports\Columns\Column; +use Javaabu\Exports\ModelExport; + +class UsersExport extends ModelExport +{ + public function modelClass(): string + { + return User::class; + } + + public function columns(): array + { + return [ + 'name', + 'email', + 'status', // enum: its label + 'role.name', // relation, eager loaded per chunk + 'organizations.name', // to-many: joined with ", " + Column::make('national_id')->label(__('National ID')), + Column::make('created_at')->format(fn ($value) => $value->toDateString()), + ]; + } +} +``` + +Without `columns()`, the export takes the table columns minus hidden attributes and secrets, and replaces `role_id` with the `role` relation. Often no `columns()` is needed at all. + +## Using it + +```php +// small exports, during the request +return UsersExport::make()->download('users.xlsx'); + +// a filtered query +UsersExport::make(User::query()->where('status', 'approved'))->download('approved-users.csv'); + +// to a disk +$path = UsersExport::make()->store('reports/users.xlsx', disk: 's3'); + +// big exports, on the queue; the user is notified with a download link +$export = UsersExport::make($query)->queue($request->user(), format: 'xlsx'); + +return redirect()->route('exports.show', $export); +``` + +Authorize before exporting, as for listing: `$this->authorize('viewAny', User::class);`. + +## The exports page + +```php +// routes/web.php, inside the app's group +Route::exports(); +Route::exports(['guard' => 'web_admin', 'prefix' => 'admin/exports', 'name' => 'admin.exports']); + +// AppServiceProvider::boot() +Exports::register('users', UsersExport::class); +``` + +Permissions: `view_exports`, `view_others_exports`, `delete_exports`, `delete_others_exports`. Run `php artisan permissions:sync`. Schedule `exports:prune` daily. + +## Rules + +- Don't load models yourself (`User::all()`, `->get()`) or map rows by hand; pass a query and let the export stream it. +- Don't call `with()` for the columns' relations; the export eager loads them. Constrained eager loads you add yourself are kept. +- Keep the primary key in any `select()`. An `orderBy()` makes the export page with offsets; drop it for huge tables. +- Override `formatValue()` for a single attribute and call `parent::formatValue()` for the rest. +- An export passed to `queue()` is serialized: hold a query, ids and filters, not collections. +- Use `queue()` beyond a few tens of thousands of rows. +- Headings go through `__()`: add Dhivehi for new headings to `lang/dv.json`. +- Don't use maatwebsite/excel concerns (`WithHeadings`, `FromQuery`, `Excel::download()`) with these classes. + +See [references/common-mistakes.md](references/common-mistakes.md) for more, and [references/queued-exports.md](references/queued-exports.md) for the Export model, events and live progress. diff --git a/resources/boost/skills/javaabu-exports/references/common-mistakes.md b/resources/boost/skills/javaabu-exports/references/common-mistakes.md new file mode 100644 index 0000000..8ed1839 --- /dev/null +++ b/resources/boost/skills/javaabu-exports/references/common-mistakes.md @@ -0,0 +1,90 @@ +# Common mistakes with javaabu/exports + +## Loading everything + +```php +// Wrong: loads every user into memory +$users = User::with('role')->get(); +return IterableExport::make(['Name'], $users->map(fn ($user) => [$user->name]))->download(); + +// Right: the export streams the query and eager loads per chunk +return UsersExport::make(User::query())->download(); +``` + +## Using v1 / maatwebsite APIs + +```php +// Wrong (v1) +return Excel::download(new UsersExport, 'users.xlsx'); + +// Right +return UsersExport::make()->download('users.xlsx'); +``` + +## Overriding formatValue without the parent + +```php +// Wrong: every other column loses its formatting (enums, booleans, dates, relations) +public function formatValue(Model $model, string $attribute, mixed $value): mixed +{ + return $attribute === 'amount' ? $value / 100 : $value; +} + +// Right +public function formatValue(Model $model, string $attribute, mixed $value): mixed +{ + if ($attribute === 'amount') { + return $value / 100; + } + + return parent::formatValue($model, $attribute, $value); +} +``` + +## Unserializable queued exports + +```php +// Wrong: a loaded collection and a closure can't go on the queue +class UsersExport extends ModelExport +{ + public function __construct(public Collection $users, public Closure $filter) {} +} + +// Right: pass a query (serialized as SQL) or plain values +UsersExport::make(User::query()->where('status', 'approved'))->queue($user); +``` + +## Selecting without the key + +```php +// Wrong: lazyById() needs the primary key +UsersExport::make(User::query()->select('name', 'email')); + +// Right +UsersExport::make(User::query()->select('id', 'name', 'email')); +``` + +## Hard-coding the guard or user model + +```php +// Wrong +Route::exports(['middleware' => ['auth:web']]); // and then using it for admins + +// Right: one registration per guard +Route::exports(['guard' => 'web_admin', 'prefix' => 'admin/exports', 'name' => 'admin.exports']); +``` + +## Changing the package policy in place + +Override it in the app instead: + +```php +// config/permissions.php +'policies' => [ + \Javaabu\Exports\Models\Export::class => \App\Policies\ExportPolicy::class, +], +``` + +## Signed links with renamed routes + +If the routes are registered as `admin.exports`, set `exports.routes.signed_download` to `admin.exports.signed-download`, or notification emails have no download button. diff --git a/resources/boost/skills/javaabu-exports/references/queued-exports.md b/resources/boost/skills/javaabu-exports/references/queued-exports.md new file mode 100644 index 0000000..e807d05 --- /dev/null +++ b/resources/boost/skills/javaabu-exports/references/queued-exports.md @@ -0,0 +1,43 @@ +# Queued exports, the Export model and live progress + +```php +$export = UsersExport::make($query)->queue($request->user(), format: 'csv', filename: 'ބޭނުންތެރިން'); +``` + +- Creates a `Javaabu\Exports\Models\Export` (`status` pending) and dispatches `Javaabu\Exports\Jobs\ProcessExport` on `exports.queue.connection` / `exports.queue.queue`. +- The job writes the whole file, saving `processed_rows` every `exports.queue.progress_every` rows, stops if the export is cancelled, stores it at `exports/{uuid}/{filename}` on the disk, and notifies the user (`Notifiable` needed) with a signed link. +- Files expire after `exports.expire_after_days`; `exports:prune` deletes them. +- The file is written in the locale active when `queue()` was called. + +```php +$export->progress(); // 0–100 or null +$export->status; // ExportStatuses::PENDING|PROCESSING|COMPLETED|FAILED|CANCELLED +$export->isDownloadable(); +$export->download(); // streamed response +$export->cancel(); +Export::query()->ownedBy($user)->latest()->get(); +``` + +Events in `Javaabu\Exports\Events`: `ExportStarted`, `ExportProgressed`, `ExportCompleted`, `ExportFailed`, `ExportCancelled`, each with `$export`. + +## Live progress + +With laravel/reverb installed and broadcasting on, the events broadcast as `export.progressed` etc. on `private-exports.{uuid}` and `private-exports.owner.{type}.{id}`. Listen with a leading dot: + +```ts +useEcho(`exports.${id}`, ['.export.progressed', '.export.completed'], (payload) => update(payload)); +``` + +Without Reverb, poll `GET /exports/{uuid}` (JSON) every `meta.poll_interval` ms until `is_finished`. + +## Testing + +```php +Queue::fake(); +UsersExport::make()->queue($user); +Queue::assertPushed(ProcessExport::class); + +Storage::fake('local'); +$export = UsersExport::make()->queue($user)->fresh(); // sync queue runs it +Storage::disk('local')->assertExists($export->path); +``` diff --git a/src/Jobs/ProcessExport.php b/src/Jobs/ProcessExport.php index bd55c19..14f84fb 100644 --- a/src/Jobs/ProcessExport.php +++ b/src/Jobs/ProcessExport.php @@ -42,14 +42,33 @@ class ProcessExport implements ShouldQueue public bool $deleteWhenMissingModels = true; + /** The locale the export was queued in, used for its headings and values */ + public ?string $locale; + public function __construct( public Export $export, public Exportable $exportable, ) { $this->timeout = (int) config('exports.queue.timeout', 3600); + $this->locale = app()->getLocale(); } public function handle(SpreadsheetWriter $writer): void + { + $previous_locale = app()->getLocale(); + + if ($this->locale) { + app()->setLocale($this->locale); + } + + try { + $this->process($writer); + } finally { + app()->setLocale($previous_locale); + } + } + + protected function process(SpreadsheetWriter $writer): void { $export = $this->export; diff --git a/src/Support/Broadcasting.php b/src/Support/Broadcasting.php index 393eab2..d6c9aab 100644 --- a/src/Support/Broadcasting.php +++ b/src/Support/Broadcasting.php @@ -25,7 +25,7 @@ public static function enabled(): bool $enabled = config('exports.broadcasting.enabled', 'auto'); if ($enabled !== 'auto') { - return (bool) $enabled; + return filter_var($enabled, FILTER_VALIDATE_BOOLEAN); } if (! class_exists(static::$reverb_class)) { diff --git a/tests/Feature/BroadcastingTest.php b/tests/Feature/BroadcastingTest.php index 2f0100c..474c76d 100644 --- a/tests/Feature/BroadcastingTest.php +++ b/tests/Feature/BroadcastingTest.php @@ -148,6 +148,9 @@ public function it_detects_reverb(): void config()->set('exports.broadcasting.enabled', true); $this->assertTrue(Broadcasting::enabled()); + + config()->set('exports.broadcasting.enabled', 'false'); + $this->assertFalse(Broadcasting::enabled()); } #[Test] diff --git a/tests/Feature/QueuedExportsTest.php b/tests/Feature/QueuedExportsTest.php index 93d4f38..24488d8 100644 --- a/tests/Feature/QueuedExportsTest.php +++ b/tests/Feature/QueuedExportsTest.php @@ -243,6 +243,29 @@ public function it_skips_exports_cancelled_before_the_job_runs(): void Event::assertNotDispatched(ExportStarted::class); } + #[Test] + public function it_writes_the_file_in_the_locale_it_was_queued_in(): void + { + Queue::fake(); + + app()->setLocale('dv'); + + $export = UsersExport::make()->queue(null, 'csv', 'users'); + + app()->setLocale('en'); + + Queue::assertPushed(ProcessExport::class, function (ProcessExport $job) { + $job->handle(app(SpreadsheetWriter::class)); + + return true; + }); + + $rows = $this->readSpreadsheet(Storage::disk('exports')->path($export->fresh()->path)); + + $this->assertEquals(['އައިޑީ', 'އީމެއިލް', 'ނަން'], array_slice($rows[0], 0, 3)); + $this->assertEquals('en', app()->getLocale()); + } + #[Test] public function it_does_not_notify_when_notifications_are_off(): void { From 026b716b51f4e2f528f5e8c51279637ebb5b204b Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 13:56:56 +0500 Subject: [PATCH 08/13] Add the exports pages in Blade and Inertia React Blade pages (exports::exports.index and .show) built with javaabu/forms v2 components: a form to start a registered export, a status filter, a table with status, a progress bar, rows, owner and actions, and pagination. They extend the app's layout (exports.layout) and load exports::progress through javaabu/manifest, which follows unfinished rows over Echo or by polling. Inertia pages (exports::Exports/Index and /Show) in TypeScript with @javaabu/forms and @javaabu/onu's ProgressBar, registered with manifest's registerAssets() (pages, @javaabu/exports alias, npm dependencies). They get their translated text in a strings prop and follow progress with useExportProgress(). Both stacks use logical utilities only, and a test rejects physical ones. Tests: Blade rendering (English and Dhivehi, permissions, flash messages, required assets), Inertia props, manifest registration, and Vitest for the hook, the progress component and the Blade script. CI gets a JS job. Closes #10 Closes #9 Closes #5 Closes #7 Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 5 + .github/workflows/tests.yml | 27 + .gitignore | 1 + CHANGELOG.md | 1 + composer.json | 11 + config/exports.php | 8 + .../overriding-controllers-and-views.md | 163 + docs/advanced-usage/translations.md | 9 + docs/basic-usage/exports-page-and-api.md | 11 +- docs/installation-and-setup.md | 2 + docs/requirements.md | 3 +- lang/dv.json | 27 +- lang/en.json | 27 +- package.json | 28 + pnpm-lock.yaml | 4266 +++++++++++++++++ resources/js/Pages/Exports/Index.tsx | 121 + resources/js/Pages/Exports/Show.tsx | 80 + resources/js/components/ExportActions.tsx | 45 + resources/js/components/ExportProgress.tsx | 35 + resources/js/hooks/useExportProgress.ts | 113 + resources/js/index.ts | 5 + resources/js/progress.ts | 163 + resources/js/types.ts | 123 + resources/views/exports/index.blade.php | 84 + .../views/exports/partials/actions.blade.php | 20 + .../views/exports/partials/progress.blade.php | 14 + .../views/exports/partials/row.blade.php | 34 + resources/views/exports/show.blade.php | 80 + src/Enums/ExportStatuses.php | 14 + src/Enums/Formats.php | 10 +- src/ExportsServiceProvider.php | 28 + src/Http/Controllers/ExportsController.php | 46 + src/Http/Resources/ExportResource.php | 1 + src/Support/Broadcasting.php | 1 + tests/Feature/FrontendAssetsTest.php | 27 + tests/Http/BladePagesTest.php | 165 + tests/Http/InertiaPagesTest.php | 100 + tests/TestCase.php | 4 + .../Providers/TestServiceProvider.php | 3 + tests/TestSupport/views/app.blade.php | 5 + tests/TestSupport/views/layouts/app.blade.php | 7 + tests/js/ExportProgress.test.tsx | 26 + tests/js/fixtures.ts | 45 + tests/js/progress.test.ts | 30 + tests/js/setup.ts | 14 + tests/js/useExportProgress.test.tsx | 84 + tsconfig.json | 20 + vitest.config.ts | 10 + 48 files changed, 6137 insertions(+), 9 deletions(-) create mode 100644 docs/advanced-usage/overriding-controllers-and-views.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 resources/js/Pages/Exports/Index.tsx create mode 100644 resources/js/Pages/Exports/Show.tsx create mode 100644 resources/js/components/ExportActions.tsx create mode 100644 resources/js/components/ExportProgress.tsx create mode 100644 resources/js/hooks/useExportProgress.ts create mode 100644 resources/js/index.ts create mode 100644 resources/js/progress.ts create mode 100644 resources/js/types.ts create mode 100644 resources/views/exports/index.blade.php create mode 100644 resources/views/exports/partials/actions.blade.php create mode 100644 resources/views/exports/partials/progress.blade.php create mode 100644 resources/views/exports/partials/row.blade.php create mode 100644 resources/views/exports/show.blade.php create mode 100644 tests/Feature/FrontendAssetsTest.php create mode 100644 tests/Http/BladePagesTest.php create mode 100644 tests/Http/InertiaPagesTest.php create mode 100644 tests/TestSupport/views/app.blade.php create mode 100644 tests/TestSupport/views/layouts/app.blade.php create mode 100644 tests/js/ExportProgress.test.tsx create mode 100644 tests/js/fixtures.ts create mode 100644 tests/js/progress.test.ts create mode 100644 tests/js/setup.ts create mode 100644 tests/js/useExportProgress.test.tsx create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitattributes b/.gitattributes index f23a7c9..f954f98 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,8 @@ /tests export-ignore /AGENTS.md export-ignore /CLAUDE.md export-ignore +/node_modules export-ignore +/package.json export-ignore +/pnpm-lock.yaml export-ignore +/tsconfig.json export-ignore +/vitest.config.ts export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 22f021f..14a3fad 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,33 @@ jobs: - name: Check code style run: vendor/bin/pint --test + js: + runs-on: ubuntu-latest + + name: React pages (typecheck, tests) + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + benchmark: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 8b64e81..0294f77 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage .phpunit.result.cache .phpunit.cache .idea +node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d3d15..62ad60a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,3 +18,4 @@ All notable changes will be documented in this file. - `ExportPolicy` and the permissions `view_exports`, `view_others_exports`, `delete_exports`, `delete_others_exports`, registered with javaabu/permissions. Exports belong to any user model through a morph; nothing assumes the `web` guard. - `Exports::register('users', UsersExport::class)` lets the API start an export by name. - Live progress: export events broadcast on private channels (`exports.{uuid}`, `exports.owner.{type}.{id}`) when laravel/reverb is installed and broadcasting is on (`exports.broadcasting.enabled`: auto, true or false). The API tells pages whether to listen or to poll, and how often. +- Exports pages in Blade (javaabu/forms v2 components) and Inertia React (TypeScript, @javaabu/forms and @javaabu/onu, shipped through javaabu/manifest), chosen with `exports.views`, with live progress, in English and Dhivehi, right-to-left ready. diff --git a/composer.json b/composer.json index d625059..f699851 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,14 @@ } ], "repositories": [ + { + "type": "vcs", + "url": "https://github.com/Javaabu/forms" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/manifest" + }, { "type": "vcs", "url": "https://github.com/Javaabu/permissions" @@ -34,11 +42,14 @@ "illuminate/queue": "^12.0 || ^13.0", "illuminate/routing": "^12.0 || ^13.0", "illuminate/support": "^12.0 || ^13.0", + "javaabu/forms": "^2.0@dev", + "javaabu/manifest": "^1.0@dev", "javaabu/permissions": "^3.0@dev", "laravel/serializable-closure": "^2.0", "openspout/openspout": "^5.0" }, "require-dev": { + "inertiajs/inertia-laravel": "^2.0", "javaabu/boost-javaabu-guidelines": "^1.0", "laravel/pint": "^1.24", "laravel/reverb": "^1.12", diff --git a/config/exports.php b/config/exports.php index 0c42f31..fe7c5ce 100644 --- a/config/exports.php +++ b/config/exports.php @@ -269,6 +269,14 @@ 'views' => env('EXPORTS_VIEWS', 'blade'), + /* + | The Blade layout the pages extend, and the section they fill. + */ + + 'layout' => 'layouts.app', + + 'layout_section' => 'content', + /* |-------------------------------------------------------------------------- | Controllers and authorization diff --git a/docs/advanced-usage/overriding-controllers-and-views.md b/docs/advanced-usage/overriding-controllers-and-views.md new file mode 100644 index 0000000..4746c1e --- /dev/null +++ b/docs/advanced-usage/overriding-controllers-and-views.md @@ -0,0 +1,163 @@ +--- +title: Overriding controllers and views +sidebar_position: 4 +--- + +# Choosing the stack + +```php +// config/exports.php +'views' => 'blade', // or 'inertia' +``` + +Requests that expect JSON always get JSON, whichever stack you choose. + +# Setting up the pages + +## Blade + +The Blade pages use [javaabu/forms](https://github.com/Javaabu/forms) components, so they follow your `forms.framework` (Onu or Bootstrap 6). They extend your layout: + +```php +// config/exports.php +'layout' => 'layouts.app', // the view the pages @extends +'layout_section' => 'content', // the section they fill +``` + +Live progress on the Blade pages comes from a small script, `exports::progress`, delivered by [javaabu/manifest](https://github.com/Javaabu/manifest). The pages require it themselves; your app needs manifest's Vite plugin: + +```js +// vite.config.js +import manifest from "./vendor/javaabu/manifest/dist/vite.js" + +export default defineConfig({ + plugins: [ + laravel({ input: ["resources/css/app.css", "resources/js/app.js"] }), + manifest(), + ], +}) +``` + +The pages use Tailwind utilities from Onu's theme (logical ones only, so they work right-to-left). Let Tailwind see them: + +```css +/* resources/css/app.css */ +@source "../../vendor/javaabu/exports/resources/views"; +``` + +## Inertia React + +The pages are `exports::Exports/Index` and `exports::Exports/Show`, written in TypeScript with [@javaabu/forms](https://github.com/Javaabu/react-forms) and [@javaabu/onu](https://www.npmjs.com/package/@javaabu/onu). javaabu/manifest ships them to your app; resolve them with `resolvePage()`: + +```tsx +// resources/js/app.tsx +import { createInertiaApp } from "@inertiajs/react" +import { resolvePage } from "@javaabu/manifest/inertia" +import { createRoot } from "react-dom/client" + +createInertiaApp({ + resolve: (name) => resolvePage(name, import.meta.glob("./pages/**/*.tsx")), + setup({ el, App, props }) { + createRoot(el).render() + }, +}) +``` + +Install the npm packages the pages need, and check them: + +```bash +composer require inertiajs/inertia-laravel +php artisan manifest:check # prints the install command for anything missing +``` + +They need `@javaabu/forms`, `@javaabu/onu`, `react`, `react-dom` and `@inertiajs/react`. Wrap your app in `FormsProvider` (with the Onu and Inertia adapters) as @javaabu/forms describes, so forms submit as Inertia visits. Your persistent layout wraps the pages as it wraps your own. + +The pages' text comes from the server, translated, in the `strings` prop, so they're in Dhivehi when the app's locale is `dv`. For live progress over Reverb, set up Laravel Echo as `window.Echo`; otherwise the pages poll. + +You can also use the pages' parts in your own pages: + +```tsx +import { ExportActions, ExportProgress, useExportProgress } from "@javaabu/exports" +import type { ExportData } from "@javaabu/exports" +``` + +# Page props + +| Prop | Pages | What | +| --- | --- | --- | +| `title` | both | The translated page title | +| `broadcasting` | both | Whether progress is broadcast; poll otherwise | +| `poll_interval` | both | Milliseconds between polls | +| `routes` | both | Route names by action, e.g. `routes.show` is `admin.exports.show` | +| `urls` | both | `index` and `store` URLs | +| `strings` | both (Inertia only) | The pages' translated text | +| `exports` | index | A paginator (Blade) or a paginated `ExportResource` collection (Inertia) | +| `available` | index | Registered exports the user may start: `[{ type, label }]` | +| `formats` | index | `[{ value, label }]`, from `exports.formats` | +| `statuses` | index | `[{ value, label }]` | +| `sees_others` | index | Whether the user sees everyone's exports | +| `filters` | index | `{ status, type, mine }` from the query string | +| `export` | show | The Export model (Blade) or an `ExportResource` array (Inertia) | + +# Overriding Blade views + +```bash +php artisan vendor:publish --tag=exports-views +``` + +Laravel uses `resources/views/vendor/exports/exports/index.blade.php` in place of the package's. Override one view and leave the rest: `exports.index`, `exports.show`, and the partials `exports.partials.row`, `exports.partials.progress` and `exports.partials.actions`. Keep the `data-export-*` attributes if you want live progress. + +# Overriding Inertia pages + +Copy a page into your app and edit it: + +```bash +php artisan manifest:override exports js/Pages/Exports/Index.tsx +``` + +It goes to `resources/vendor/exports/js/Pages/Exports/Index.tsx`, and `resolvePage()` uses it instead of the package's. + +# Overriding the controller + +Extend the controller and override what you need: + +```php +namespace App\Http\Controllers; + +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\Request; +use Javaabu\Exports\Http\Controllers\ExportsController as BaseExportsController; + +class ExportsController extends BaseExportsController +{ + // Only show exports from this tenant + protected function query(Request $request): Builder + { + return parent::query($request)->where('tenant_id', $request->user()->tenant_id); + } +} +``` + +Then use it in one of three ways: + +```php +// config/exports.php: every Route::exports() call +'controllers' => [ + 'exports' => \App\Http\Controllers\ExportsController::class, +], +``` + +```php +// one registration +Route::exports(['controller' => \App\Http\Controllers\ExportsController::class]); +``` + +```php +// the container, in a service provider +$this->app->bind( + \Javaabu\Exports\Http\Controllers\ExportsController::class, + \App\Http\Controllers\ExportsController::class, +); +``` + +Useful methods to override: `query()`, `findExport()`, `makeExportable()`, `mayRun()`, `availableExports()`, `formats()`, `meta()`, `strings()` and `downloadResponse()`. diff --git a/docs/advanced-usage/translations.md b/docs/advanced-usage/translations.md index a2016ab..962f67f 100644 --- a/docs/advanced-usage/translations.md +++ b/docs/advanced-usage/translations.md @@ -19,6 +19,10 @@ To change a translation, add the same key to your app's `lang/dv.json`. App line - XLSX sheets are right-to-left when the locale is in `exports.rtl_locales` (or `exports.rtl` is `true`). - File names may be in Thaana: `ބޭނުންތެރިން.xlsx`. Downloads send them with `filename*` (RFC 6266). +# Right-to-left pages + +The Blade and React pages use logical utilities only (`ms-`, `pe-`, `start-`, `text-start`, `inline-size`), so they mirror when the page is `dir="rtl"`; a test rejects physical ones. Progress bars fill from the inline start. File names and other free text get `dir="auto"`. + # Strings for review These Dhivehi translations were written by the package authors and should be checked by a native speaker: @@ -33,3 +37,8 @@ These Dhivehi translations were written by the package authors and should be che | View other users' exports | އެހެން ފަރާތްތަކުގެ އެކްސްޕޯޓްތައް ބެލުން | "Other parties'" | | Your :name export could not be completed… | ތިބާގެ :name އެކްސްޕޯޓް ނިންމޭކަށް ނުކެރުނު… | Tone of the apology | | Your export has started. We will let you know when it is ready. | އެކްސްޕޯޓް ފެށިއްޖެ. ތައްޔާރުވުމުން އަންގާނަން. | | +| Progress | ކުރިއެރުން | Literally "advancement"; ޕްރޮގްރެސް is also used | +| Started by | ފެށީ | Column heading for who started the export | +| Actions | ޢަމަލުތައް | | +| Filter | ފިލްޓަރ | Loanword | +| :processed of :total rows | :total ރޯގެ ތެރެއިން :processed | "Rows" as ރޯ (loanword) | diff --git a/docs/basic-usage/exports-page-and-api.md b/docs/basic-usage/exports-page-and-api.md index 3e6941f..f14832b 100644 --- a/docs/basic-usage/exports-page-and-api.md +++ b/docs/basic-usage/exports-page-and-api.md @@ -174,4 +174,13 @@ Content-Type: application/json # Pages -Without `Accept: application/json`, `index` and `show` render a page, and `store`, `cancel` and `destroy` redirect back with a `success` or `error` flash message. Which stack renders them is set by `exports.views`: `blade` (default) or `inertia`. See [Overriding controllers and views](../advanced-usage/overriding-controllers-and-views.md) for the data each page gets. +Without `Accept: application/json`, `index` and `show` render a page, and `store`, `cancel` and `destroy` redirect with a `success` or `error` flash message. + +| Page | Blade view | Inertia page | +| --- | --- | --- | +| List, with the form to start an export | `exports::exports.index` | `exports::Exports/Index` | +| One export | `exports::exports.show` | `exports::Exports/Show` | + +The list shows each export's name, file name, format, status, a progress bar, rows, who started it (for users who see others' exports), when, and download, cancel and delete buttons. Rows update live while exports run, then the page reloads to show the download button. + +Which stack renders them is set by `exports.views`: `blade` (default) or `inertia`. Both need a little setup in the app (a layout, or the Inertia page resolver, and manifest's Vite plugin). See [Overriding controllers and views](../advanced-usage/overriding-controllers-and-views.md), which also lists every page prop. diff --git a/docs/installation-and-setup.md b/docs/installation-and-setup.md index b69fd78..2b98c29 100644 --- a/docs/installation-and-setup.md +++ b/docs/installation-and-setup.md @@ -81,6 +81,8 @@ use Javaabu\Exports\Exports; Exports::register('users', UsersExport::class); ``` +The pages extend your `layouts.app` layout (`exports.layout`) and need manifest's Vite plugin for live progress; for Inertia React, set `exports.views` to `inertia`. See [Overriding controllers and views](advanced-usage/overriding-controllers-and-views.md#setting-up-the-pages). + See [Exports page and API](basic-usage/exports-page-and-api.md). # Cleaning up diff --git a/docs/requirements.md b/docs/requirements.md index 23ac10a..cc56907 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -5,7 +5,8 @@ sidebar_position: 1.1 - PHP 8.4 or 8.5, with the `zip`, `xmlreader` and `dom` extensions (for XLSX and ODS) - Laravel 12 or 13 -- [javaabu/permissions](https://github.com/Javaabu/permissions) v3, installed with this package +- [javaabu/permissions](https://github.com/Javaabu/permissions) v3, [javaabu/forms](https://github.com/Javaabu/forms) v2 and [javaabu/manifest](https://github.com/Javaabu/manifest) v1, installed with this package +- For the exports pages: Vite with manifest's plugin. The Inertia React pages also need `@javaabu/forms`, `@javaabu/onu`, React 18.3 or 19 and `@inertiajs/react` 2 or 3 in the app (`php artisan manifest:check` lists them). Optional: diff --git a/lang/dv.json b/lang/dv.json index 45734c5..bf47bd7 100644 --- a/lang/dv.json +++ b/lang/dv.json @@ -37,5 +37,30 @@ "View own exports": "އަމިއްލަ އެކްސްޕޯޓްތައް ބެލުން", "View other users' exports": "އެހެން ފަރާތްތަކުގެ އެކްސްޕޯޓްތައް ބެލުން", "Delete own exports": "އަމިއްލަ އެކްސްޕޯޓްތައް ފުހެލުން", - "Delete other users' exports": "އެހެން ފަރާތްތަކުގެ އެކްސްޕޯޓްތައް ފުހެލުން" + "Delete other users' exports": "އެހެން ފަރާތްތަކުގެ އެކްސްޕޯޓްތައް ފުހެލުން", + "New export": "އާ އެކްސްޕޯޓެއް", + "Export": "އެކްސްޕޯޓް", + "Format": "ފޯމެޓް", + "Start export": "އެކްސްޕޯޓް ފައްޓަވާ", + "All statuses": "ހުރިހާ ހާލަތެއް", + "Only my exports": "އަހަރެންގެ އެކްސްޕޯޓްތައް އެކަނި", + "Filter": "ފިލްޓަރ", + "Progress": "ކުރިއެރުން", + "Rows": "ރޯތައް", + "Started by": "ފެށީ", + "Created": "އުފެއްދީ", + "Expires": "މުއްދަތު ހަމަވާނީ", + "Actions": "ޢަމަލުތައް", + "Cancel": "ކެންސަލް", + "Delete": "ފުހެލާ", + "Cancel this export?": "މި އެކްސްޕޯޓް ކެންސަލް ކުރަންވީތޯ؟", + "Delete this export and its file?": "މި އެކްސްޕޯޓާއި ފައިލު ފުހެލަންވީތޯ؟", + "No exports yet": "އަދި އެއްވެސް އެކްސްޕޯޓެއް ނެތް", + "Exports you start will appear here.": "ތިބާ ފައްޓަވާ އެކްސްޕޯޓްތައް މިތަނުގައި ފެންނާނެ.", + ":processed of :total rows": ":total ރޯގެ ތެރެއިން :processed", + ":count rows": ":count ރޯ", + "Error": "މައްސަލަ", + "Back to exports": "އެކްސްޕޯޓްތަކަށް އެނބުރި", + "File": "ފައިލު", + "Size": "ސައިޒު" } diff --git a/lang/en.json b/lang/en.json index 6fde7a8..8a39d2d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -37,5 +37,30 @@ "View own exports": "View own exports", "View other users' exports": "View other users' exports", "Delete own exports": "Delete own exports", - "Delete other users' exports": "Delete other users' exports" + "Delete other users' exports": "Delete other users' exports", + "New export": "New export", + "Export": "Export", + "Format": "Format", + "Start export": "Start export", + "All statuses": "All statuses", + "Only my exports": "Only my exports", + "Filter": "Filter", + "Progress": "Progress", + "Rows": "Rows", + "Started by": "Started by", + "Created": "Created", + "Expires": "Expires", + "Actions": "Actions", + "Cancel": "Cancel", + "Delete": "Delete", + "Cancel this export?": "Cancel this export?", + "Delete this export and its file?": "Delete this export and its file?", + "No exports yet": "No exports yet", + "Exports you start will appear here.": "Exports you start will appear here.", + ":processed of :total rows": ":processed of :total rows", + ":count rows": ":count rows", + "Error": "Error", + "Back to exports": "Back to exports", + "File": "File", + "Size": "Size" } diff --git a/package.json b/package.json new file mode 100644 index 0000000..556f5b4 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "javaabu-exports-dev", + "private": true, + "description": "Development tooling for the Inertia React pages of javaabu/exports. Apps don't install this; they get the pages through javaabu/manifest.", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "check": "pnpm typecheck && pnpm test" + }, + "devDependencies": { + "@inertiajs/react": "^2.0.0", + "@javaabu/forms": "github:Javaabu/react-forms#next-gen", + "@javaabu/onu": "^0.2.1", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^26.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.6.0", + "vitest": "^3.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": ["@javaabu/forms", "@javaabu/i18n", "esbuild"] + }, + "packageManager": "pnpm@10.28.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..57bffde --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4266 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@inertiajs/react': + specifier: ^2.0.0 + version: 2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/forms': + specifier: github:Javaabu/react-forms#next-gen + version: https://codeload.github.com/Javaabu/react-forms/tar.gz/2f8f494adf33748763f8a876ff0f0912eb060c88(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4))(@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/onu': + specifier: ^0.2.1 + version: 0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4) + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@types/react': + specifier: ^19.0.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.3.0(@types/react@19.3.0) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + react: + specifier: ^19.0.0 + version: 19.3.1 + react-dom: + specifier: ^19.0.0 + version: 19.3.1(react@19.3.1) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(jsdom@26.1.0) + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + + '@dnd-kit/abstract@0.5.3': + resolution: {integrity: sha512-KZQ2vftkO9BHupVzlUqB5d7JETL7ITlvslx3hrj4aObwjliAzuzV/n5wCpQllNd0Hxvg7FaQtLekJ8dplqKwbA==} + + '@dnd-kit/collision@0.5.3': + resolution: {integrity: sha512-DBo8czkI72xnsHRWOMTn/GAyYgSWy9bmde85mrZ7ShqLhcbIbcu4PVJ3ZU9MBikePCSAVOoUTZE2gS40jogrEw==} + + '@dnd-kit/dom@0.5.3': + resolution: {integrity: sha512-JJe+Qu/JmofGzHes3np91sQYwp5spvLcknjRkq+VO59JTqSnQNPXlhHXavRXTwQFabaaRyRzXyH/faAvubCNGw==} + + '@dnd-kit/geometry@0.5.3': + resolution: {integrity: sha512-U+zscNjoHfJ1xUV+XDGF89jPCoILLivWSb/U9mfM7h83f6o3X9AS9uIwG46bXUpCqPvlI/9JcOlyga5uvfyjgw==} + + '@dnd-kit/helpers@0.5.3': + resolution: {integrity: sha512-fErbbjwKMJEdr7e+AMsXaZ6aQY4xPYBMK+nqybB34tE6/lQDLetZHsgLDKNVePsYIZCq/7uWMtlFjqysscWg5A==} + + '@dnd-kit/react@0.5.3': + resolution: {integrity: sha512-BGn6bakgcRSoKMRQP8cRIGu59e5lGC3/sL0zVWmDMo/9CMPSxgjCcQltDn5TDTwAARnEbuwNEksor4rLtH/yEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.5.3': + resolution: {integrity: sha512-NJTQkBglAT/SXLc+IOLiKwNvsTrWmPG5AO+7Gn0F0n8XyVaf4VCbTCuPuU48sDBfcpg23O0tfGkfZNiZbYTvMw==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@inertiajs/core@2.3.29': + resolution: {integrity: sha512-Cx3zibnKdsmEnXpsWhwmE9i4k5NFtK+4gtceL3Kb6RwYC1J0wi5y/EesNMkpdJLsAT2h4ugUvu9Ed9T1EUl0/A==} + + '@inertiajs/react@2.3.29': + resolution: {integrity: sha512-09SWBPxv8UrJdOWtQrEp1ro6jv1qJ8yOxH8eL+na9K/wbh7k9R0ALvAO3ofY52Cwuh7HMuSPgtRzk/q8E1k0Fg==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@internationalized/date@3.12.4': + resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} + + '@internationalized/number@3.6.9': + resolution: {integrity: sha512-GgWy2caO/+hV9sT/oCTWGHvbjmJMPT9kkXzoKRLnkEq7adghhR7UprhvY9sXrKfrp3NcDfhF6QQHXR1KfQbB8g==} + + '@internationalized/string@3.2.10': + resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/2f8f494adf33748763f8a876ff0f0912eb060c88': + resolution: {tarball: https://codeload.github.com/Javaabu/react-forms/tar.gz/2f8f494adf33748763f8a876ff0f0912eb060c88} + version: 0.0.0 + engines: {node: '>=20.19'} + peerDependencies: + '@inertiajs/react': ^2.0.0 || ^3.0.0 + '@javaabu/onu': ^0.2.1 + '@testing-library/react': ^16.0.0 + react: ^18.3.0 || ^19.0.0 + react-dom: ^18.3.0 || ^19.0.0 + peerDependenciesMeta: + '@inertiajs/react': + optional: true + '@javaabu/onu': + optional: true + '@testing-library/react': + optional: true + + '@javaabu/i18n@https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba': + resolution: {tarball: https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba} + version: 0.0.0 + engines: {node: '>=20.19'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + vue: ^3.3.0 + peerDependenciesMeta: + react: + optional: true + vue: + optional: true + + '@javaabu/onu@0.2.1': + resolution: {integrity: sha512-GE4takAQwNQqD9ZmQooH7KMBrLUoiR4UzJsDCGr6cLYigoLF22tjRD3CzrdVGVl+SAhz2jjwOozxzyvAyTNmxA==} + peerDependencies: + '@deck.gl/aggregation-layers': ^9.4.0 + '@deck.gl/core': ^9.4.0 + '@deck.gl/google-maps': ^9.4.0 + '@deck.gl/layers': ^9.4.0 + '@fontsource-variable/geist': ^5.3.0 + '@fontsource-variable/inter': ^5.3.0 + '@fontsource-variable/jetbrains-mono': ^5.3.0 + '@fontsource-variable/noto-sans-thaana': ^5.3.0 + '@fontsource-variable/source-serif-4': ^5.3.0 + '@fortawesome/fontawesome-svg-core': ^7.3.1 + '@fortawesome/react-fontawesome': ^3.5.0 + '@fullcalendar/core': ^6.1.21 + '@fullcalendar/daygrid': ^6.1.21 + '@fullcalendar/interaction': ^6.1.21 + '@fullcalendar/list': ^6.1.21 + '@fullcalendar/react': ^6.1.21 + '@fullcalendar/timegrid': ^6.1.21 + '@googlemaps/markerclusterer': ^2.6.2 + '@iconify/react': ^6.0.2 + '@tanstack/react-query': ^5.104.1 + '@tiptap/core': ^3.31.4 + '@tiptap/extension-image': ^3.31.4 + '@tiptap/extension-placeholder': ^3.31.4 + '@tiptap/extension-table': ^3.31.4 + '@tiptap/extension-text-align': ^3.31.4 + '@tiptap/pm': ^3.31.4 + '@tiptap/react': ^3.31.4 + '@tiptap/starter-kit': ^3.31.4 + '@tiptap/static-renderer': ^3.31.4 + '@tiptap/suggestion': ^3.31.4 + '@turf/turf': ^7.4.0 + '@vis.gl/react-google-maps': ^1.10.1 + barcode-detector: ^3.2.2 + media-chrome: ^4.19.3 + react: ^19.0.0 + react-dom: ^19.0.0 + react-easy-crop: ^6.2.4 + react-hook-form: ^7.89.0 + recharts: ^3.10.1 + shiki: ^4.5.0 + signature_pad: ^5.1.4 + tailwindcss: ^4.0.0 + terra-draw: ^1.37.0 + terra-draw-google-maps-adapter: ^1.7.0 + write-excel-file: ^4.1.1 + yet-another-react-lightbox: ^3.32.2 + peerDependenciesMeta: + '@deck.gl/aggregation-layers': + optional: true + '@deck.gl/core': + optional: true + '@deck.gl/google-maps': + optional: true + '@deck.gl/layers': + optional: true + '@fontsource-variable/geist': + optional: true + '@fontsource-variable/inter': + optional: true + '@fontsource-variable/jetbrains-mono': + optional: true + '@fontsource-variable/noto-sans-thaana': + optional: true + '@fontsource-variable/source-serif-4': + optional: true + '@fortawesome/fontawesome-svg-core': + optional: true + '@fortawesome/react-fontawesome': + optional: true + '@fullcalendar/core': + optional: true + '@fullcalendar/daygrid': + optional: true + '@fullcalendar/interaction': + optional: true + '@fullcalendar/list': + optional: true + '@fullcalendar/react': + optional: true + '@fullcalendar/timegrid': + optional: true + '@googlemaps/markerclusterer': + optional: true + '@iconify/react': + optional: true + '@tiptap/core': + optional: true + '@tiptap/extension-image': + optional: true + '@tiptap/extension-placeholder': + optional: true + '@tiptap/extension-table': + optional: true + '@tiptap/extension-text-align': + optional: true + '@tiptap/pm': + optional: true + '@tiptap/react': + optional: true + '@tiptap/starter-kit': + optional: true + '@tiptap/static-renderer': + optional: true + '@tiptap/suggestion': + optional: true + '@turf/turf': + optional: true + '@vis.gl/react-google-maps': + optional: true + barcode-detector: + optional: true + media-chrome: + optional: true + react-easy-crop: + optional: true + recharts: + optional: true + shiki: + optional: true + signature_pad: + optional: true + terra-draw: + optional: true + terra-draw-google-maps-adapter: + optional: true + write-excel-file: + optional: true + yet-another-react-lightbox: + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@preact/signals-core@1.14.5': + resolution: {integrity: sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==} + + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.2.0': + resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==} + + '@radix-ui/react-accessible-icon@1.2.0': + resolution: {integrity: sha512-BB4+HHkFl3HipEAhm/mLhrpXV+2kRX7MvY4AaImUcQVeWQt5KhV1JEByNs8NGfJOcr95RcQGijt9+OrCo68xGA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.3.1': + resolution: {integrity: sha512-lCZ6sbGS/7WO0j84yx4srIgOf0MRJ/sZ13RonADXxvKBym8xrxNCq5f6DibGcr38sIlwPk8/52uYdXeOPsbEDw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.2.1': + resolution: {integrity: sha512-MWC4N7RY+qsbNrFNo7yQ9ZbdoZEW+B5DtpWxJfUnVCqcQxxqqg3qis3RMPTDbCNaTIdHJSn5XDkl9ZdVWXknLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.17': + resolution: {integrity: sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.2.0': + resolution: {integrity: sha512-gHEU+Lx8DlT/FYIjZQvvBJrFSwUngXTjh7FeL9QvJaNALDLez6pgDj2axCUihBjtPBy82LN0gNb25+sTcSXNmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.3.1': + resolution: {integrity: sha512-ssZOkfQNkFpYVmCPQlr978xSaU7moSrGXdwg4j2Xs2WlGl/1KsSZtfpK+WnLnx0Rwll1BAh6oUrAy/uR31SBvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.4.1': + resolution: {integrity: sha512-KWtwhh+98ML3seZ9vO9yRY2OwaQG90dCiDAxjpSNY2AaZ3Hik3B0dsk6m0ILywo/no3A9/SozadyPMNweVT8yw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.2.1': + resolution: {integrity: sha512-fSzLb/qy1wuZSxQBcxpW0hNXYHDMWiIXDGyJGdA4+FSLknOWZsNRrTCUavxb4PI8b8lvHo/CysO6NVi5zLpo5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.2.0': + resolution: {integrity: sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.4.1': + resolution: {integrity: sha512-Pddn3Z8lio3/ChxyXaEP3jHTTo1LTHCWwF8+mG1chQlWijP+ttVHBKwezrQTiV6Mj670ojvumqvuGh+mdv33iQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.3.0': + resolution: {integrity: sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.3.1': + resolution: {integrity: sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.2.0': + resolution: {integrity: sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.2.1': + resolution: {integrity: sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.2.1': + resolution: {integrity: sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.2.0': + resolution: {integrity: sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.3.1': + resolution: {integrity: sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.3.0': + resolution: {integrity: sha512-KnmrCM27m7Jo1R8S6cFDPrEeq20Jfn5dqde0SZrJr2lVXlj6/QZSUCyFoYh1d8ti0uUrMNK5Lx6dUDuINT+f4Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.2.1': + resolution: {integrity: sha512-4QooHxgmpCKRjj1Pft+hETKidYcrbPKcVwMGtk2Oa5DcWY2IH1fHuQt9LG6q5hUQBBV2MhQxSvtx6B/C1xfiTw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.17': + resolution: {integrity: sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.2.1': + resolution: {integrity: sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.2.1': + resolution: {integrity: sha512-bRTYtS9JITV2dUYqYZb7+N5SooQBkZrNRejfmyrSKe3Y57aXhKgx//kBUP0kY7pEpsWR/U1AHsAJRzjwCrK3PA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.4.1': + resolution: {integrity: sha512-XPPZjkeyCSMNNvt0FyDv/PUauG7Ow4GwHQ/XZSwW36F/TS7rR4HFTsXH8ewLnXTOLn3x96/pmTPNQIVoTX/86g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.2.1': + resolution: {integrity: sha512-KtHdcgnaAK6rohQ/MMJoGYcbgSRJsIuGBdHrFeTB1/9SaKlDs14oSOniW6BL6M43HqvgCO3u4WuVdFLkuc6sqw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.2.1': + resolution: {integrity: sha512-vu4+ZoJAaFjdI7l1lDSZRs+4dEv83Jmu7lyzajPrAeQzX74y84n0rUNWbWSgN9XB5NE8IY86Z02kGH6oRrzR+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.3.1': + resolution: {integrity: sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.4.0': + resolution: {integrity: sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.2.0': + resolution: {integrity: sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.11': + resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.2.0': + resolution: {integrity: sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.2.1': + resolution: {integrity: sha512-yQBiM6J3YM8Ums1orkEFpbznK47geKxa0igikzcaS1xHgdba3DdAQBsGbCTt5BVfR8zhEvgxQ0poGcINaD3lvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.5.1': + resolution: {integrity: sha512-QbyVT1ddnjqB9H+CUYKkNWdU9fIK3QHCYtjf7mS6ekSqiuXkWtWn5UZW9mhA8qtl3vrVyIsmR9gePstAtKrqkw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.2.1': + resolution: {integrity: sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.4.0': + resolution: {integrity: sha512-CFrLqHNejWq5FhKAcj9V/WPGRsol76HBQto81NdOBVFEOqIEkJkKIJt0Z824A/9hHLCtoiR/Kw3euRgzXP2pxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.4.1': + resolution: {integrity: sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.2.0': + resolution: {integrity: sha512-1jUAap0wZEqVP05Ou7kJgRxe/W+Qftng+rUNWGa0qkKbv9r5pWTG3E7a3ELKo2eJoHKMECnzuYrZpSyO1YgcYQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.6.1': + resolution: {integrity: sha512-IRNRR90moPqxfwCJ0XbMJIxo70W0/1ZExT/sNCxEHMaPx0SF9HoQrcPEC75+H28cFKa0b+KH63anfLYJmwKacw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.5.0': + resolution: {integrity: sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.4.1': + resolution: {integrity: sha512-NpBGwc40rY02ZmSvuyeKk0Bvc8ZsNeTMi4qHvJ+ZH4EiYCv0zIXEgdCW4bVdzgIUeP9xxbLJSxKOUfqITYNmkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.2.1': + resolution: {integrity: sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.3.1': + resolution: {integrity: sha512-iQvL0J7XK4HtJNGkkWMyYrboNseI7vtotFeUkIno8YNxVA9oM7B6//fh0Mc6TSKI96+zioLWi98lQLso88yJmQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.2.1': + resolution: {integrity: sha512-O8Ldx5ao4p4KoW+DO8/PmkOe5mdKNi3F2SseqjVj88wvDGDHe60cUmO0Zm/uHsKrvNgQGV8Efpuh95Tht/h4vw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.2.1': + resolution: {integrity: sha512-+3MUq+WyiBplXYqQ0EIYlq8R4sLQ0T5NbOK7DMdDLCKO93pe2uE0DUJTWWfTBprpJJXxfoF3eMW0xpakVZrYsg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.2.1': + resolution: {integrity: sha512-Cm8m7JTEeUdbfA6T9xgyu8HfOpyvSx8pB/wj0DlEuWaJe9FilIs4Az9wci/lU9RbthV/UkltszMw5WnnD+m/Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.4.1': + resolution: {integrity: sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.5': + resolution: {integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.3.0': + resolution: {integrity: sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.6': + resolution: {integrity: sha512-9cRullnQRv+kqnRmFlmh0WC3kAPuGmHiQ9VwsXdrYk//sBhgCKEttcpPEPgIQbyx6TGUmooVEHYTyohwz/lSJg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.5': + resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.13': + resolution: {integrity: sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@react-types/shared@3.37.0': + resolution: {integrity: sha512-VNlZuifu4tHdxWPfHvoMfKsHA9kVTCGkduv5vKVTdinm8wHFJNhyVB5RzIMiRFj0q3RKoCbP68u2zaoBSiFueg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@rollup/rollup-android-arm-eabi@4.67.4': + resolution: {integrity: sha512-/v/pUAPOcwp+r1Hjjdg0cBV1TE89CzGKrKh2FgkIQw2QBcIMmVPOCSfuOZStCn4Xh/98HXfo/ht6ztcssctPNg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.67.4': + resolution: {integrity: sha512-6tmmB/P52Vt167FXXEw5auiEUgTtZz7aqUgWHqYXToIUE0HFDY6fTdAyeekjjsNk6O/8dtpSKtClxSj1mMneKQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-android-x64@4.67.4': + resolution: {integrity: sha512-8Z9Q4RmotVmuL4llOvqX814JNiOK1oqoBv+glG5KbpRfUiypqzz2/fxIzK4Pq129l4dT5a8Gszmpc0/XPYoFuQ==} + cpu: [x64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.67.4': + resolution: {integrity: sha512-pnq6hssxTtwuRAl6Kdi/+IDY+b1IhkUetjhUkdLAAJCssjfqtL2Oqy/FXJVJFtAWOu1oRlArIvf0Yk1O92x2HQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.67.4': + resolution: {integrity: sha512-BeIrGZvHMVdJg/N+ohZfFywG/gF41684YhDj2wBvouj7m9ziP1tZDUNwzXRiDY+3WIKngaC4hiUBqVtBPGP5og==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.67.4': + resolution: {integrity: sha512-fRFX/oqNg/g780DKoS8RIfYcZD59Ru5O/bSeK1N7gQljBFrTxJY/q5EX4eMs5SrJf32VgVBVVYCdfPpzAwPPnQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.67.4': + resolution: {integrity: sha512-bSrBRopG1q+0RuOsW0NtYTtIODlvNA3T3ebx5WnQVdSxWIoXFmj+rZdHUHdSLgFdZ5M6VGmekYE7r4RMGQXjfg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.67.4': + resolution: {integrity: sha512-L8IvdrJVtl65g1ozwDSyjGPG+FmibhCdFUwmqTItJLw0S3+Oj4QzFs7rfgTUiK7rsPjkCLdG1/GApFxoynXFJQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.67.4': + resolution: {integrity: sha512-h7PcoQOzC31jshNpQ48J0akB9tsghjkSjSl5AZ6XsVGTxVsln7t7C4SQyw62tNVSaz2SeHgk4VT2ELbL17BSHQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.67.4': + resolution: {integrity: sha512-KSPuOBNHoOl7AsVjeY1KZci8+0up0U4TFAHU5IzBYN8P+Z6cB9DZq28+JQycZW7f6WLLa/tSzhV7Rdy95bVi+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.67.4': + resolution: {integrity: sha512-0E/mKmVk1PlQeQYygIwQm0jlwjANTzGSiNBMPrFx+rdFGFj/2Axh4p6XwcrystQIqXZVudTaGsdbDpzYgJ4wSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.67.4': + resolution: {integrity: sha512-Fvw7dOSc2Sa9UOipRZVI0zOgdLCzsjVyD/6JuHXzUHTXFu7mMgmeX/0/bCVBIut1dz+nWz3pV4WpS/Qg0XVnxw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.67.4': + resolution: {integrity: sha512-jzxbVHaE7V3F+P0wEK7UydQtvv7Twfdtx58jI5/3VRx1jJxyrn+qFwfXmqjVFxPhBl+zjaRs+ZJMFJw7vdHaQA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.67.4': + resolution: {integrity: sha512-fhtrzp9p4GjEJVaigaap9g6X9nP6xgJtKiLLXSDgpE18HXufA5aqfltGn+KAqMtVZReJZfOrXm//y5YSA9BWbA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.67.4': + resolution: {integrity: sha512-uynaF7IUHwAckV0S+yC1f5esy9gB+aqCBsPH5BOck32TYdCuAn2lVG5tvUcFnEsrviGEmRZxE1NHSOieUPYJEg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.67.4': + resolution: {integrity: sha512-uaqRFdfb8eIa2bVHN/FE3ndCvR0zV2xv0AwjCfxrqSu6l6fp/54VhjfhslKlf2RIik9L1jwlt4E56PeVX6tQtQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.67.4': + resolution: {integrity: sha512-JZRow7kmxUDEASnxHuVG5itTm+nTXi9BQUABcVUnUEngC+OPwMrpx1uJ9hIA/v+IcAEBL52ktCSPXk5OJn9Jqg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.67.4': + resolution: {integrity: sha512-rdrh4e+Lq9fCNKoDPaCqeHXbQCe7yl77Ukl45tlSClwYdZ1gpFGZz1XLJdYtY++FRlfL5aHvb6/0n3GDW/Nxsw==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.67.4': + resolution: {integrity: sha512-wL7MS4r0fYcxluF3ne2plofHZiB/LsqNCnyaUlyiKpAJLb6agPSFz7DEFCfkonWAZXMrLwCmC08b2ZEnEdJkog==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.67.4': + resolution: {integrity: sha512-4H0XHfd2VufGjX7NjUyeue78sD0bRIoKSGjGqO+F5nSvL6jN48NAU7esdmtFEYxZXwObLtt3MJ/kHS0jASMW3g==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.67.4': + resolution: {integrity: sha512-osRHQbE55pZpCLetpMsFmhkapyZnAUH73qRKMh+npi3boUom6u6hdaTTPSxlInXxwJHsn9r2uDhdz0V7Yoxx2A==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.67.4': + resolution: {integrity: sha512-95+exYEyOwrZePsE0czi4q7X5lxKPa21+lmyAZlwbYENITQ0Unk4HAQAFp/p+KhXBHBx9mUABXRYjOC5A1wY3Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.67.4': + resolution: {integrity: sha512-FOXOcKhNQpO4Po+Ynan6IbtM67SrXZbgpRJWVYwz53CT5veI7sVzDwVBmWS65N5Imv+wK3SVhIPAl+C5DXXNCg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.67.4': + resolution: {integrity: sha512-cklKh0gJOkAZQcWziQa3hrjxZKN+PCHZ7SPTyGJDAiBVr05ZXlEjGFCG7zFJMaVibxsmzAe1FimN4u/HBns9+Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.67.4': + resolution: {integrity: sha512-AegwxJPc9bvUp8fQtlsUHkFMOMkGylWU7glyL+YD4vwG+QaYoGXYLSsQoeTD7kvoLZrVlMWNVhTg1dedY5C1VQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.67.4': + resolution: {integrity: sha512-RJZnrwTWj7Iq2ooBwoGbDdC65tDwYawneGcAfFMJqVUrRZMcskka6nTkBpXR/47G7kzn3EW2qupExnaBy11vwg==} + cpu: [x64] + os: [win32] + + '@sindresorhus/slugify@3.0.1': + resolution: {integrity: sha512-N1oPvN6sytRKZCskv0L+vSyvnUYi9JplPXZ90lT6EBsijjHrUdoXttb0Lna6pw1TWMe8vlajQnLDtU9KYHqtDg==} + engines: {node: '>=20'} + + '@sindresorhus/transliterate@2.3.1': + resolution: {integrity: sha512-gVaaGtKYMYAMmI8buULVH3A2TXVJ98QiwGwI7ddrWGuGidGC2uRt4FHs22+8iROJ0QTzju9CuMjlVsrvpqsdhA==} + engines: {node: '>=20'} + + '@swc/helpers@0.5.24': + resolution: {integrity: sha512-6tcXo4kA8HgGO7AVmawB57RYi/piBPYVHVcO6dXjX+SZw8GoHmi262sp4v2Ha2kNP9Cx84qei27yT+fjS1lxgg==} + + '@tanstack/query-core@5.105.3': + resolution: {integrity: sha512-v1rbNy7MB/4Akth7CQH+CerV13K7Ok9hAusEsfz4sCjDdu3/kOSJPoWfoB+YjdEX1eBFBWKNAGE5nqZCgdlHDw==} + + '@tanstack/react-query@5.105.3': + resolution: {integrity: sha512-dj5hGWZmwoc9NG4enoXuYbiYwb5FxgJYPVv06eUgicMTfEMTRXn08Xv9D/r1V8TfjAv+BpxdvZFzJ1h3hSWZcQ==} + peerDependencies: + '@types/react': ^18 || ^19 + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@tanstack/react-store@0.11.2': + resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-table@9.3.1': + resolution: {integrity: sha512-E6Ya5TnY6EaGyZYIUqig/29Lr/gJSAubQkLJFqiWkUl5nRCr0Xr9hvJot+NiRTtG+TzGxHTiqUW3bnLtQxlHug==} + engines: {node: '>=20'} + peerDependencies: + react: '>=18' + + '@tanstack/react-virtual@3.14.26': + resolution: {integrity: sha512-HUDlfsrYH6ko5xH56L62Mcl/uzT+AGVNMHFuDNqDJMdZWc1AQa2oeVKNUvZ/RWBZQ4TYtyQerFrNvLnp39h65A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/store@0.11.2': + resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + + '@tanstack/table-core@9.3.1': + resolution: {integrity: sha512-zQvlxSNi2qGM3L4jh85Z6hqMfdIR5qOfD9KHOU32nuKFJGN0RlJ7UvV1lsPb1fPVhpFYI0VP3jJGxLdLDpTKjQ==} + engines: {node: '>=20'} + + '@tanstack/virtual-core@3.19.11': + resolution: {integrity: sha512-/5iHb0RHBvj8Di59FDs4ESNm45jls33aZAi4zccVXZjc/6ZvwQP6nzEYeT6weXjTf9E6/vrp2JhYWRyO+ZP13A==} + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.27': + resolution: {integrity: sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.21.0: + resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + input-format@0.3.14: + resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} + peerDependencies: + react: '>=18.1.0' + react-dom: '>=18.1.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + input-otp@1.5.0: + resolution: {integrity: sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + laravel-precognition@1.0.2: + resolution: {integrity: sha512-0H08JDdMWONrL/N314fvsO3FATJwGGlFKGkMF3nNmizVFJaWs17816iM+sX7Rp8d5hUjYCx6WLfsehSKfaTxjg==} + + libphonenumber-js@1.13.15: + resolution: {integrity: sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lucide-react@1.68.0: + resolution: {integrity: sha512-9i7NKWm2QlOprMhcwkhBOlIt9zB7cp2GIyFY+oMue0EsprolUFxva5rkdBbyfs4k1eejpa0JRnRgVOGiart+Xg==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.17.1: + resolution: {integrity: sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==} + engines: {node: '>=0.6'} + + radix-ui@1.8.1: + resolution: {integrity: sha512-HPfpVGizO9xANlsFSauruLhRe/3+iOt3vPX0MXYle/UQi3gorQrSYtBcdiv0BZYewcl2rvL59qrIuLFMGAmUrw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + react-aria-components@1.22.1: + resolution: {integrity: sha512-UTHaYHOaM71bOCjGPF50DcLywlrwAodE63IP64LB4kO+OizatQEL8TesVEOM8+TkBeSgHoflJ45388IlJfa7aw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-aria@3.53.1: + resolution: {integrity: sha512-dikisyOjJd3D2fFrIDWmVWzqiLz5B86GzZv1zxs7xI4dVc0JIz/1Wa52I20gYvVL2WXeFXlygYsle/QcOMhtcg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-day-picker@10.0.2: + resolution: {integrity: sha512-QetiKtZWE9Uaw/pM/SYNv6WPYOTRnSJ2puL0GgwkmUFMIwiY0dtjH1W4B7DOVXVNQM0ZOClGpH/heOfBSzWeiA==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react-hook-form@7.91.0: + resolution: {integrity: sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-phone-number-input@3.4.18: + resolution: {integrity: sha512-EmPhJFSxQB7CTmLAlQfGAQQPF+WmJECU08gjyUt50zd2iESb4ETsm6z+BRfUEjH+TXct4EWvJvub+OztJxb/Cg==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-stately@3.51.0: + resolution: {integrity: sha512-YJcmkAXu2XOQP97hO/qz/cC3qv9kni3oVhwBlxWr358BHePJUpY15XwL3YSRMKxYt4EkslPqAz11JRirMfANGw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + rollup@4.67.4: + resolution: {integrity: sha512-Lsv3oKcz5jgOQJej/p7nfwbVEdAbDT+z6OPf/SCdI+nF+q2ENp0pTKT35ZwXAmH8g45mfrIiJeEyAqKYSYoL9Q==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonner@2.0.8: + resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + + tailwindcss@4.3.4: + resolution: {integrity: sha512-PCJZTFFdG+sL3gvnP1Do9o6GbkvH2q0p04xSs3f7cSvmAeK+sttG+/7ncK1fdRwKFfC7n+wDDJaOMBSefkI9ow==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tslib@2.9.1: + resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.7: + resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + 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 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.22.1: + resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.10': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@date-fns/tz@1.5.0': {} + + '@dnd-kit/abstract@0.5.3': + dependencies: + '@dnd-kit/geometry': 0.5.3 + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/collision@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/geometry': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/dom@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/collision': 0.5.3 + '@dnd-kit/geometry': 0.5.3 + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/geometry@0.5.3': + dependencies: + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/helpers@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/react@0.5.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/dom': 0.5.3 + '@dnd-kit/state': 0.5.3 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + tslib: 2.9.1 + + '@dnd-kit/state@0.5.3': + dependencies: + '@preact/signals-core': 1.14.5 + tslib: 2.9.1 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@floating-ui/utils@0.2.12': {} + + '@inertiajs/core@2.3.29': + dependencies: + '@types/lodash-es': 4.17.12 + axios: 1.21.0 + laravel-precognition: 1.0.2 + lodash-es: 4.18.1 + qs: 6.17.1 + transitivePeerDependencies: + - debug + - supports-color + + '@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@inertiajs/core': 2.3.29 + '@types/lodash-es': 4.17.12 + laravel-precognition: 1.0.2 + lodash-es: 4.18.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - debug + - supports-color + + '@internationalized/date@3.12.4': + dependencies: + '@swc/helpers': 0.5.24 + + '@internationalized/number@3.6.9': + dependencies: + '@swc/helpers': 0.5.24 + + '@internationalized/string@3.2.10': + dependencies: + '@swc/helpers': 0.5.24 + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/2f8f494adf33748763f8a876ff0f0912eb060c88(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4))(@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@javaabu/i18n': https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@inertiajs/react': 2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/onu': 0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4) + '@testing-library/react': 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + transitivePeerDependencies: + - vue + + '@javaabu/i18n@https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1)': + optionalDependencies: + react: 19.3.1 + + '@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4)': + dependencies: + '@dnd-kit/dom': 0.5.3 + '@dnd-kit/helpers': 0.5.3 + '@dnd-kit/react': 0.5.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@internationalized/date': 3.12.4 + '@sindresorhus/slugify': 3.0.1 + '@tanstack/react-query': 5.105.3(@types/react@19.3.0)(react@19.3.1) + '@tanstack/react-table': 9.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/react-virtual': 3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + date-fns: 4.4.0 + embla-carousel-react: 8.6.0(react@19.3.1) + input-otp: 1.5.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + lucide-react: 1.68.0(@types/react@19.3.0)(react@19.3.1) + radix-ui: 1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-aria-components: 1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-day-picker: 10.0.2(@types/react@19.3.0)(react@19.3.1) + react-dom: 19.3.1(react@19.3.1) + react-hook-form: 7.91.0(@types/react@19.3.0)(react@19.3.1) + react-phone-number-input: 3.4.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + sonner: 2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + tailwind-merge: 3.7.0 + tailwindcss: 4.3.4 + tw-animate-css: 1.4.0 + uqr: 0.1.3 + vaul: 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@preact/signals-core@1.14.5': {} + + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.2.0': {} + + '@radix-ui/react-accessible-icon@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-accordion@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collapsible': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-alert-dialog@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-arrow@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-aspect-ratio@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-avatar@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-checkbox@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collapsible@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collection@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-context-menu@2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-context@1.3.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dialog@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-direction@1.2.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dismissable-layer@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-dropdown-menu@2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-focus-guards@1.2.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-focus-scope@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-form@0.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-label': 2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-hover-card@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-id@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-label@2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-menu@2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-menubar@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-navigation-menu@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-one-time-password-field@0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-password-toggle-field@0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popover@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popper@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/rect': 1.1.3 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-portal@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-presence@1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-primitive@2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-progress@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-radio-group@1.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-roving-focus@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-scroll-area@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-select@2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-separator@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slider@1.6.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slot@1.5.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-switch@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-tabs@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toast@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toggle-group@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toggle@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toolbar@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-separator': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle-group': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-tooltip@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-use-callback-ref@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-controllable-state@1.3.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-escape-keydown@1.1.6(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-previous@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-rect@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-size@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-visually-hidden@1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/rect@1.1.3': {} + + '@react-types/shared@3.37.0(react@19.3.1)': + dependencies: + react: 19.3.1 + + '@rollup/rollup-android-arm-eabi@4.67.4': + optional: true + + '@rollup/rollup-android-arm64@4.67.4': + optional: true + + '@rollup/rollup-android-x64@4.67.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.67.4': + optional: true + + '@rollup/rollup-darwin-x64@4.67.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.67.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.67.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.67.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.67.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.67.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.67.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.67.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.67.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.67.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.67.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.67.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.67.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.67.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.67.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.67.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.67.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.67.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.67.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.67.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.67.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.67.4': + optional: true + + '@sindresorhus/slugify@3.0.1': + dependencies: + '@sindresorhus/transliterate': 2.3.1 + escape-string-regexp: 5.0.0 + + '@sindresorhus/transliterate@2.3.1': {} + + '@swc/helpers@0.5.24': {} + + '@tanstack/query-core@5.105.3': {} + + '@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@tanstack/query-core': 5.105.3 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@tanstack/react-store@0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/store': 0.11.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + '@tanstack/react-table@9.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/react-store': 0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/table-core': 9.3.1 + react: 19.3.1 + transitivePeerDependencies: + - react-dom + + '@tanstack/react-virtual@3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/virtual-core': 3.19.11 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@tanstack/store@0.11.2': {} + + '@tanstack/table-core@9.3.1': + dependencies: + '@tanstack/store': 0.11.2 + + '@tanstack/virtual-core@3.19.11': {} + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.10 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@testing-library/dom': 10.4.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.10': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.27 + + '@types/lodash@4.17.27': {} + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.7)': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.7 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.9.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + axios@1.21.0: + dependencies: + follow-redirects: 1.16.2 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + classnames@2.5.1: {} + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + country-flag-icons@1.6.20: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + date-fns@4.4.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-node-es@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + embla-carousel-react@8.6.0(react@19.3.1): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.3.1 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-string-regexp@5.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.10 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + follow-redirects@1.16.2: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + input-format@0.3.14(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + prop-types: 15.8.1 + optionalDependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + input-otp@1.5.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.22.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + laravel-precognition@1.0.2: + dependencies: + axios: 1.21.0 + lodash-es: 4.18.1 + transitivePeerDependencies: + - debug + - supports-color + + libphonenumber-js@1.13.15: {} + + lodash-es@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lucide-react@1.68.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + nwsapi@2.2.28: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + qs@6.17.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + radix-ui@1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-accessible-icon': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-accordion': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-alert-dialog': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-aspect-ratio': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-avatar': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-checkbox': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collapsible': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context-menu': 2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-dropdown-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-form': 0.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-hover-card': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-label': 2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-menubar': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-navigation-menu': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-one-time-password-field': 0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-password-toggle-field': 0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popover': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-progress': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-radio-group': 1.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-scroll-area': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-select': 2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-separator': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slider': 1.6.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-switch': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tabs': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toast': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle-group': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toolbar': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tooltip': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.6(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + react-aria-components@1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + client-only: 0.0.1 + react: 19.3.1 + react-aria: 3.53.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-dom: 19.3.1(react@19.3.1) + react-stately: 3.51.0(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + react-aria@3.53.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.9 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-stately: 3.51.0(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-phone-number-input@3.4.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + classnames: 2.5.1 + country-flag-icons: 1.6.20 + input-format: 0.3.14(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + libphonenumber-js: 1.13.15 + prop-types: 15.8.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-remove-scroll@2.7.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@19.3.0)(react@19.3.1) + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + use-callback-ref: 1.3.3(@types/react@19.3.0)(react@19.3.1) + use-sidecar: 1.1.3(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + react-stately@3.51.0(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.9 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + react: 19.3.1 + use-sync-external-store: 1.7.0(react@19.3.1) + + react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + get-nonce: 1.0.1 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react@19.3.1: {} + + rollup@4.67.4: + dependencies: + '@types/estree': 1.0.10 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.67.4 + '@rollup/rollup-android-arm64': 4.67.4 + '@rollup/rollup-android-x64': 4.67.4 + '@rollup/rollup-darwin-arm64': 4.67.4 + '@rollup/rollup-darwin-x64': 4.67.4 + '@rollup/rollup-freebsd-arm64': 4.67.4 + '@rollup/rollup-freebsd-x64': 4.67.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.67.4 + '@rollup/rollup-linux-arm-musleabihf': 4.67.4 + '@rollup/rollup-linux-arm64-gnu': 4.67.4 + '@rollup/rollup-linux-arm64-musl': 4.67.4 + '@rollup/rollup-linux-loong64-gnu': 4.67.4 + '@rollup/rollup-linux-loong64-musl': 4.67.4 + '@rollup/rollup-linux-ppc64-gnu': 4.67.4 + '@rollup/rollup-linux-ppc64-musl': 4.67.4 + '@rollup/rollup-linux-riscv64-gnu': 4.67.4 + '@rollup/rollup-linux-riscv64-musl': 4.67.4 + '@rollup/rollup-linux-s390x-gnu': 4.67.4 + '@rollup/rollup-linux-x64-gnu': 4.67.4 + '@rollup/rollup-linux-x64-musl': 4.67.4 + '@rollup/rollup-openbsd-x64': 4.67.4 + '@rollup/rollup-openharmony-arm64': 4.67.4 + '@rollup/rollup-win32-arm64-msvc': 4.67.4 + '@rollup/rollup-win32-ia32-msvc': 4.67.4 + '@rollup/rollup-win32-x64-gnu': 4.67.4 + '@rollup/rollup-win32-x64-msvc': 4.67.4 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.28.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + source-map-js@1.2.2: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + symbol-tree@3.2.4: {} + + tailwind-merge@3.7.0: {} + + tailwindcss@4.3.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tslib@2.9.1: {} + + tw-animate-css@1.4.0: {} + + typescript@5.9.3: {} + + uqr@0.1.3: {} + + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sidecar@1.1.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sync-external-store@1.7.0(react@19.3.1): + dependencies: + react: 19.3.1 + + vaul@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.7 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.7: + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.32 + rollup: 4.67.4 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.7(jsdom@26.1.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.7) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.7 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.22.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/resources/js/Pages/Exports/Index.tsx b/resources/js/Pages/Exports/Index.tsx new file mode 100644 index 0000000..7a18090 --- /dev/null +++ b/resources/js/Pages/Exports/Index.tsx @@ -0,0 +1,121 @@ +import { Card, Checkbox, Form, Pagination, Select, Status, Submit, Table, TableCell, TableEmptyRow, TableHeading, TableRow } from "@javaabu/forms"; +import { router } from "@inertiajs/react"; +import { ExportActions } from "../../components/ExportActions"; +import { ExportProgress } from "../../components/ExportProgress"; +import { useExportProgress } from "../../hooks/useExportProgress"; +import type { ExportsIndexProps } from "../../types"; + +/** exports::Exports/Index — the user's exports, a form to start one, and live progress */ +export default function Index(props: ExportsIndexProps) { + const { strings, urls, sees_others: seesOthers } = props; + + const exports = useExportProgress(props.exports.data, { + broadcasting: props.broadcasting, + pollInterval: props.poll_interval, + onFinished: () => router.reload({ only: ["exports"] }), + }); + + const columns = seesOthers ? 7 : 6; + + return ( +
+

{props.title}

+ + {props.available.length > 0 ? ( + +
+ [option.value, option.label]))} + default={props.formats[0]?.value} + /> + {strings.start} +
+
+ ) : null} + +
+