From 5116d83d388d7994d5ce62b63c3c512fdf37bbc5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 9 Jan 2025 16:46:13 +0000 Subject: [PATCH 01/56] PHP: Updated min version to 8.2 PHPStan config not yet compatible, but should work after moving to Laravel 11, which would allow using larastan 3.x. --- .github/workflows/test-php.yml | 2 +- composer.json | 4 +- composer.lock | 95 +++++++++++++++++----------------- phpstan.neon.dist | 4 +- 4 files changed, 54 insertions(+), 51 deletions(-) diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index ee9cf39bc..277af9070 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4'] steps: - uses: actions/checkout@v4 diff --git a/composer.json b/composer.json index b8d8da9e7..426602e9a 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "license": "MIT", "type": "project", "require": { - "php": "^8.1.0", + "php": "^8.2.0", "ext-curl": "*", "ext-dom": "*", "ext-fileinfo": "*", @@ -104,7 +104,7 @@ "preferred-install": "dist", "sort-packages": true, "platform": { - "php": "8.1.0" + "php": "8.2.0" } }, "extra": { diff --git a/composer.lock b/composer.lock index 16156f2dc..a2ceed629 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9c0520d8b0c13ae46bd0213c4dec5e38", + "content-hash": "a8875f121a0e28301e3ca8b3e63d394c", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.336.8", + "version": "3.336.11", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6" + "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6", - "reference": "933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/442039c766a82f06ecfecb0ac2c610d6aaba228d", + "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d", "shasum": "" }, "require": { @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.336.8" + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.11" }, - "time": "2025-01-03T19:06:11+00:00" + "time": "2025-01-08T19:06:59+00:00" }, { "name": "bacon/bacon-qr-code", @@ -2016,16 +2016,16 @@ }, { "name": "knplabs/knp-snappy", - "version": "v1.5.0", + "version": "v1.5.1", "source": { "type": "git", "url": "https://github.com/KnpLabs/snappy.git", - "reference": "98468898b50c09f26d56d905b79b0f52a2215da6" + "reference": "3dd138e9e47de91cd2e056c5e6e1a0dd72547ee7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/98468898b50c09f26d56d905b79b0f52a2215da6", - "reference": "98468898b50c09f26d56d905b79b0f52a2215da6", + "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/3dd138e9e47de91cd2e056c5e6e1a0dd72547ee7", + "reference": "3dd138e9e47de91cd2e056c5e6e1a0dd72547ee7", "shasum": "" }, "require": { @@ -2077,9 +2077,9 @@ ], "support": { "issues": "https://github.com/KnpLabs/snappy/issues", - "source": "https://github.com/KnpLabs/snappy/tree/v1.5.0" + "source": "https://github.com/KnpLabs/snappy/tree/v1.5.1" }, - "time": "2023-12-18T09:12:11+00:00" + "time": "2025-01-06T16:53:26+00:00" }, { "name": "laravel/framework", @@ -3448,12 +3448,12 @@ "version": "2.72.6", "source": { "type": "git", - "url": "https://github.com/briannesbitt/Carbon.git", + "url": "https://github.com/CarbonPHP/carbon.git", "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/1e9d50601e7035a4c61441a208cb5bed73e108c5", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1e9d50601e7035a4c61441a208cb5bed73e108c5", "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5", "shasum": "" }, @@ -5936,24 +5936,24 @@ }, { "name": "symfony/event-dispatcher", - "version": "v6.4.13", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e" + "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", - "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", + "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<5.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -5962,13 +5962,13 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "symfony/stopwatch": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -5996,7 +5996,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.13" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0" }, "funding": [ { @@ -6012,7 +6012,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7295,20 +7295,20 @@ }, { "name": "symfony/string", - "version": "v6.4.15", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f" + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", + "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-normalizer": "~1.0", @@ -7318,11 +7318,12 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/intl": "^6.2|^7.0", + "symfony/emoji": "^7.1", + "symfony/error-handler": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^5.4|^6.0|^7.0" + "symfony/var-exporter": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7361,7 +7362,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.15" + "source": "https://github.com/symfony/string/tree/v7.2.0" }, "funding": [ { @@ -7377,7 +7378,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:12+00:00" + "time": "2024-11-13T13:31:26+00:00" }, { "name": "symfony/translation", @@ -8784,16 +8785,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.14", + "version": "1.12.15", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "e73868f809e68fff33be961ad4946e2e43ec9e38" + "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e73868f809e68fff33be961ad4946e2e43ec9e38", - "reference": "e73868f809e68fff33be961ad4946e2e43ec9e38", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c91d4e8bc056f46cf653656e6f71004b254574d1", + "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1", "shasum": "" }, "require": { @@ -8838,7 +8839,7 @@ "type": "github" } ], - "time": "2024-12-31T07:26:13+00:00" + "time": "2025-01-05T16:40:22+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10428,7 +10429,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.1.0", + "php": "^8.2.0", "ext-curl": "*", "ext-dom": "*", "ext-fileinfo": "*", @@ -10440,7 +10441,7 @@ }, "platform-dev": {}, "platform-overrides": { - "php": "8.1.0" + "php": "8.2.0" }, "plugin-api-version": "2.6.0" } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bcf4e5aa2..aa2ad3d9e 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,7 +9,9 @@ parameters: # The level 8 is the highest level level: 1 - phpVersion: 80200 + phpVersion: + min: 80200 + max: 80400 bootstrapFiles: - bootstrap/phpstan.php From cf9ccfcd5b0186249fd904067a1f343ea2f1dd04 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Jan 2025 11:14:49 +0000 Subject: [PATCH 02/56] Framework: Performed Laravel 11 upgrade guide steps Performed a little code cleanups when observed along the way. Tested not yet ran. --- app/Access/ExternalBaseUserProvider.php | 49 +- app/App/Providers/EventServiceProvider.php | 8 + app/Config/broadcasting.php | 37 - app/Config/cache.php | 5 +- app/Config/filesystems.php | 3 + app/Config/mail.php | 10 +- app/Config/queue.php | 1 + app/Uploads/FileStorage.php | 1 - composer.json | 8 +- composer.lock | 1402 ++++++++--------- ..._04_25_140741_update_polymorphic_types.php | 4 +- public/index.php | 35 +- 12 files changed, 669 insertions(+), 894 deletions(-) delete mode 100644 app/Config/broadcasting.php diff --git a/app/Access/ExternalBaseUserProvider.php b/app/Access/ExternalBaseUserProvider.php index d3ece983f..2b5ddfbf3 100644 --- a/app/Access/ExternalBaseUserProvider.php +++ b/app/Access/ExternalBaseUserProvider.php @@ -8,27 +8,15 @@ use Illuminate\Database\Eloquent\Model; class ExternalBaseUserProvider implements UserProvider { - /** - * The user model. - * - * @var string - */ - protected $model; - - /** - * LdapUserProvider constructor. - */ - public function __construct(string $model) - { - $this->model = $model; + public function __construct( + protected string $model + ) { } /** * Create a new instance of the model. - * - * @return Model */ - public function createModel() + public function createModel(): Model { $class = '\\' . ltrim($this->model, '\\'); @@ -37,12 +25,8 @@ class ExternalBaseUserProvider implements UserProvider /** * Retrieve a user by their unique identifier. - * - * @param mixed $identifier - * - * @return Authenticatable|null */ - public function retrieveById($identifier) + public function retrieveById(mixed $identifier): ?Authenticatable { return $this->createModel()->newQuery()->find($identifier); } @@ -50,12 +34,9 @@ class ExternalBaseUserProvider implements UserProvider /** * Retrieve a user by their unique identifier and "remember me" token. * - * @param mixed $identifier * @param string $token - * - * @return Authenticatable|null */ - public function retrieveByToken($identifier, $token) + public function retrieveByToken(mixed $identifier, $token): null { return null; } @@ -75,12 +56,8 @@ class ExternalBaseUserProvider implements UserProvider /** * Retrieve a user by the given credentials. - * - * @param array $credentials - * - * @return Authenticatable|null */ - public function retrieveByCredentials(array $credentials) + public function retrieveByCredentials(array $credentials): ?Authenticatable { // Search current user base by looking up a uid $model = $this->createModel(); @@ -92,15 +69,15 @@ class ExternalBaseUserProvider implements UserProvider /** * Validate a user against the given credentials. - * - * @param Authenticatable $user - * @param array $credentials - * - * @return bool */ - public function validateCredentials(Authenticatable $user, array $credentials) + public function validateCredentials(Authenticatable $user, array $credentials): bool { // Should be done in the guard. return false; } + + public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false) + { + // No action to perform, any passwords are external in the auth system + } } diff --git a/app/App/Providers/EventServiceProvider.php b/app/App/Providers/EventServiceProvider.php index 4cd527ba4..34ab7cfef 100644 --- a/app/App/Providers/EventServiceProvider.php +++ b/app/App/Providers/EventServiceProvider.php @@ -42,4 +42,12 @@ class EventServiceProvider extends ServiceProvider { return false; } + + /** + * Overrides the registration of Laravel's default email verification system + */ + protected function configureEmailVerification(): void + { + // + } } diff --git a/app/Config/broadcasting.php b/app/Config/broadcasting.php deleted file mode 100644 index 3b95698cc..000000000 --- a/app/Config/broadcasting.php +++ /dev/null @@ -1,37 +0,0 @@ - 'null', - - // Broadcast Connections - // Here you may define all of the broadcast connections that will be used - // to broadcast events to other systems or over websockets. Samples of - // each available type of connection are provided inside this array. - 'connections' => [ - - // Default options removed since we don't use broadcasting. - - 'log' => [ - 'driver' => 'log', - ], - - 'null' => [ - 'driver' => 'null', - ], - - ], - -]; diff --git a/app/Config/cache.php b/app/Config/cache.php index b588437ff..9a0be8eab 100644 --- a/app/Config/cache.php +++ b/app/Config/cache.php @@ -35,10 +35,6 @@ return [ // Available caches stores 'stores' => [ - 'apc' => [ - 'driver' => 'apc', - ], - 'array' => [ 'driver' => 'array', 'serialize' => false, @@ -49,6 +45,7 @@ return [ 'table' => 'cache', 'connection' => null, 'lock_connection' => null, + 'lock_table' => null, ], 'file' => [ diff --git a/app/Config/filesystems.php b/app/Config/filesystems.php index 1319c8886..08ae7b047 100644 --- a/app/Config/filesystems.php +++ b/app/Config/filesystems.php @@ -33,12 +33,14 @@ return [ 'driver' => 'local', 'root' => public_path(), 'visibility' => 'public', + 'serve' => false, 'throw' => true, ], 'local_secure_attachments' => [ 'driver' => 'local', 'root' => storage_path('uploads/files/'), + 'serve' => false, 'throw' => true, ], @@ -46,6 +48,7 @@ return [ 'driver' => 'local', 'root' => storage_path('uploads/images/'), 'visibility' => 'public', + 'serve' => false, 'throw' => true, ], diff --git a/app/Config/mail.php b/app/Config/mail.php index 2906d769a..038864f8c 100644 --- a/app/Config/mail.php +++ b/app/Config/mail.php @@ -38,7 +38,7 @@ return [ 'password' => env('MAIL_PASSWORD'), 'verify_peer' => env('MAIL_VERIFY_SSL', true), 'timeout' => null, - 'local_domain' => env('MAIL_EHLO_DOMAIN'), + 'local_domain' => null, 'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'), ], @@ -64,12 +64,4 @@ return [ ], ], ], - - // Email markdown configuration - 'markdown' => [ - 'theme' => 'default', - 'paths' => [ - resource_path('views/vendor/mail'), - ], - ], ]; diff --git a/app/Config/queue.php b/app/Config/queue.php index 795a79325..08f3a5baa 100644 --- a/app/Config/queue.php +++ b/app/Config/queue.php @@ -23,6 +23,7 @@ return [ 'database' => [ 'driver' => 'database', + 'connection' => null, 'table' => 'jobs', 'queue' => 'default', 'retry_after' => 90, diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php index e6ac368d0..6e4a210a1 100644 --- a/app/Uploads/FileStorage.php +++ b/app/Uploads/FileStorage.php @@ -5,7 +5,6 @@ namespace BookStack\Uploads; use BookStack\Exceptions\FileUploadException; use Exception; use Illuminate\Contracts\Filesystem\Filesystem as Storage; -use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; diff --git a/composer.json b/composer.json index 426602e9a..58b89fcce 100644 --- a/composer.json +++ b/composer.json @@ -18,12 +18,11 @@ "ext-xml": "*", "ext-zip": "*", "bacon/bacon-qr-code": "^3.0", - "doctrine/dbal": "^3.5", "dompdf/dompdf": "^3.0", "guzzlehttp/guzzle": "^7.4", "intervention/image": "^3.5", "knplabs/knp-snappy": "^1.5", - "laravel/framework": "^10.48.23", + "laravel/framework": "^v11.37", "laravel/socialite": "^5.10", "laravel/tinker": "^2.8", "league/commonmark": "^2.3", @@ -39,14 +38,13 @@ "socialiteproviders/microsoft-azure": "^5.1", "socialiteproviders/okta": "^4.2", "socialiteproviders/twitch": "^5.3", - "ssddanbrown/htmldiff": "^1.0.2", - "ssddanbrown/symfony-mailer": "6.4.x-dev" + "ssddanbrown/htmldiff": "^1.0.2" }, "require-dev": { "fakerphp/faker": "^1.21", "itsgoingd/clockwork": "^5.1", "mockery/mockery": "^1.5", - "nunomaduro/collision": "^7.0", + "nunomaduro/collision": "^8.1", "larastan/larastan": "^2.7", "phpunit/phpunit": "^10.0", "squizlabs/php_codesniffer": "^3.7", diff --git a/composer.lock b/composer.lock index a2ceed629..1d512f851 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a8875f121a0e28301e3ca8b3e63d394c", + "content-hash": "518176ac5bb608061e0f74b06fdae582", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.336.11", + "version": "3.336.13", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d" + "reference": "dcb43c029ca74c52fa03a739341cc77086296a83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/442039c766a82f06ecfecb0ac2c610d6aaba228d", - "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/dcb43c029ca74c52fa03a739341cc77086296a83", + "reference": "dcb43c029ca74c52fa03a739341cc77086296a83", "shasum": "" }, "require": { @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.336.11" + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.13" }, - "time": "2025-01-08T19:06:59+00:00" + "time": "2025-01-10T19:04:25+00:00" }, { "name": "bacon/bacon-qr-code", @@ -274,26 +274,26 @@ }, { "name": "carbonphp/carbon-doctrine-types", - "version": "2.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", - "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb" + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", - "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": "^8.1" }, "conflict": { - "doctrine/dbal": "<3.7.0 || >=4.0.0" + "doctrine/dbal": "<4.0.0 || >=5.0.0" }, "require-dev": { - "doctrine/dbal": "^3.7.0", + "doctrine/dbal": "^4.0.0", "nesbot/carbon": "^2.71.0 || ^3.0.0", "phpunit/phpunit": "^10.3" }, @@ -323,7 +323,7 @@ ], "support": { "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", - "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/2.1.0" + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" }, "funding": [ { @@ -339,7 +339,7 @@ "type": "tidelift" } ], - "time": "2023-12-11T17:09:12+00:00" + "time": "2024-02-09T16:56:22+00:00" }, { "name": "dasprid/enum", @@ -466,348 +466,6 @@ }, "time": "2024-07-08T12:26:09+00:00" }, - { - "name": "doctrine/cache", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/cache.git", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/1ca8f21980e770095a31456042471a57bc4c68fb", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb", - "shasum": "" - }, - "require": { - "php": "~7.1 || ^8.0" - }, - "conflict": { - "doctrine/common": ">2.2,<2.4" - }, - "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/coding-standard": "^9", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psr/cache": "^1.0 || ^2.0 || ^3.0", - "symfony/cache": "^4.4 || ^5.4 || ^6", - "symfony/var-exporter": "^4.4 || ^5.4 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", - "homepage": "https://www.doctrine-project.org/projects/cache.html", - "keywords": [ - "abstraction", - "apcu", - "cache", - "caching", - "couchdb", - "memcached", - "php", - "redis", - "xcache" - ], - "support": { - "issues": "https://github.com/doctrine/cache/issues", - "source": "https://github.com/doctrine/cache/tree/2.2.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", - "type": "tidelift" - } - ], - "time": "2022-05-20T20:07:39+00:00" - }, - { - "name": "doctrine/dbal", - "version": "3.9.3", - "source": { - "type": "git", - "url": "https://github.com/doctrine/dbal.git", - "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba", - "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2", - "doctrine/cache": "^1.11|^2.0", - "doctrine/deprecations": "^0.5.3|^1", - "doctrine/event-manager": "^1|^2", - "php": "^7.4 || ^8.0", - "psr/cache": "^1|^2|^3", - "psr/log": "^1|^2|^3" - }, - "require-dev": { - "doctrine/coding-standard": "12.0.0", - "fig/log-test": "^1", - "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.12.6", - "phpstan/phpstan-strict-rules": "^1.6", - "phpunit/phpunit": "9.6.20", - "psalm/plugin-phpunit": "0.18.4", - "slevomat/coding-standard": "8.13.1", - "squizlabs/php_codesniffer": "3.10.2", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/console": "^4.4|^5.4|^6.0|^7.0", - "vimeo/psalm": "4.30.0" - }, - "suggest": { - "symfony/console": "For helpful console commands such as SQL execution and import of files." - }, - "bin": [ - "bin/doctrine-dbal" - ], - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\DBAL\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - } - ], - "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", - "homepage": "https://www.doctrine-project.org/projects/dbal.html", - "keywords": [ - "abstraction", - "database", - "db2", - "dbal", - "mariadb", - "mssql", - "mysql", - "oci8", - "oracle", - "pdo", - "pgsql", - "postgresql", - "queryobject", - "sasql", - "sql", - "sqlite", - "sqlserver", - "sqlsrv" - ], - "support": { - "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.9.3" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", - "type": "tidelift" - } - ], - "time": "2024-10-10T17:56:43+00:00" - }, - { - "name": "doctrine/deprecations", - "version": "1.1.4", - "source": { - "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psr/log": "^1 || ^2 || ^3" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Deprecations\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", - "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" - }, - "time": "2024-12-07T21:18:45+00:00" - }, - { - "name": "doctrine/event-manager", - "version": "2.0.1", - "source": { - "type": "git", - "url": "https://github.com/doctrine/event-manager.git", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "conflict": { - "doctrine/common": "<2.9" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.8.8", - "phpunit/phpunit": "^10.5", - "vimeo/psalm": "^5.24" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - }, - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com" - } - ], - "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", - "homepage": "https://www.doctrine-project.org/projects/event-manager.html", - "keywords": [ - "event", - "event dispatcher", - "event manager", - "event system", - "events" - ], - "support": { - "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/2.0.1" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", - "type": "tidelift" - } - ], - "time": "2024-05-22T20:47:39+00:00" - }, { "name": "doctrine/inflector", "version": "2.0.10", @@ -2083,23 +1741,23 @@ }, { "name": "laravel/framework", - "version": "v10.48.25", + "version": "v11.37.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f132b23b13909cc22c615c01b0c5640541c3da0c" + "reference": "6cb103d2024b087eae207654b3f4b26646119ba5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f132b23b13909cc22c615c01b0c5640541c3da0c", - "reference": "f132b23b13909cc22c615c01b0c5640541c3da0c", + "url": "https://api.github.com/repos/laravel/framework/zipball/6cb103d2024b087eae207654b3f4b26646119ba5", + "reference": "6cb103d2024b087eae207654b3f4b26646119ba5", "shasum": "" }, "require": { "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", - "dragonmantank/cron-expression": "^3.3.2", + "dragonmantank/cron-expression": "^3.4", "egulias/email-validator": "^3.2.1|^4.0", "ext-ctype": "*", "ext-filter": "*", @@ -2108,44 +1766,45 @@ "ext-openssl": "*", "ext-session": "*", "ext-tokenizer": "*", - "fruitcake/php-cors": "^1.2", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", "guzzlehttp/uri-template": "^1.0", - "laravel/prompts": "^0.1.9", - "laravel/serializable-closure": "^1.3", - "league/commonmark": "^2.2.1", - "league/flysystem": "^3.8.0", + "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.6", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", "monolog/monolog": "^3.0", - "nesbot/carbon": "^2.67", - "nunomaduro/termwind": "^1.13", - "php": "^8.1", + "nesbot/carbon": "^2.72.2|^3.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", "psr/container": "^1.1.1|^2.0.1", "psr/log": "^1.0|^2.0|^3.0", "psr/simple-cache": "^1.0|^2.0|^3.0", "ramsey/uuid": "^4.7", - "symfony/console": "^6.2", - "symfony/error-handler": "^6.2", - "symfony/finder": "^6.2", - "symfony/http-foundation": "^6.4", - "symfony/http-kernel": "^6.2", - "symfony/mailer": "^6.2", - "symfony/mime": "^6.2", - "symfony/process": "^6.2", - "symfony/routing": "^6.2", - "symfony/uid": "^6.2", - "symfony/var-dumper": "^6.2", + "symfony/console": "^7.0.3", + "symfony/error-handler": "^7.0.3", + "symfony/finder": "^7.0.3", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.0.3", + "symfony/mailer": "^7.0.3", + "symfony/mime": "^7.0.3", + "symfony/polyfill-php83": "^1.31", + "symfony/process": "^7.0.3", + "symfony/routing": "^7.0.3", + "symfony/uid": "^7.0.3", + "symfony/var-dumper": "^7.0.3", "tijsverkoyen/css-to-inline-styles": "^2.2.5", - "vlucas/phpdotenv": "^5.4.1", - "voku/portable-ascii": "^2.0" + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" }, "conflict": { - "carbonphp/carbon-doctrine-types": ">=3.0", - "doctrine/dbal": ">=4.0", - "mockery/mockery": "1.6.8", - "phpunit/phpunit": ">=11.0.0", "tightenco/collect": "<5.5.33" }, "provide": { "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", "psr/simple-cache-implementation": "1.0|2.0|3.0" }, "replace": { @@ -2154,6 +1813,7 @@ "illuminate/bus": "self.version", "illuminate/cache": "self.version", "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", "illuminate/conditionable": "self.version", "illuminate/config": "self.version", "illuminate/console": "self.version", @@ -2181,36 +1841,38 @@ "illuminate/testing": "self.version", "illuminate/translation": "self.version", "illuminate/validation": "self.version", - "illuminate/view": "self.version" + "illuminate/view": "self.version", + "spatie/once": "*" }, "require-dev": { "ably/ably-php": "^1.0", - "aws/aws-sdk-php": "^3.235.5", - "doctrine/dbal": "^3.5.1", + "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", - "fakerphp/faker": "^1.21", - "guzzlehttp/guzzle": "^7.5", - "league/flysystem-aws-s3-v3": "^3.0", - "league/flysystem-ftp": "^3.0", - "league/flysystem-path-prefixing": "^3.3", - "league/flysystem-read-only": "^3.3", - "league/flysystem-sftp-v3": "^3.0", - "mockery/mockery": "^1.5.1", - "nyholm/psr7": "^1.2", - "orchestra/testbench-core": "^8.23.4", - "pda/pheanstalk": "^4.0", - "phpstan/phpstan": "~1.11.11", - "phpunit/phpunit": "^10.0.7", - "predis/predis": "^2.0.2", - "symfony/cache": "^6.2", - "symfony/http-client": "^6.2.4", - "symfony/psr-http-message-bridge": "^2.0" + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^9.6", + "pda/pheanstalk": "^5.0.6", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^1.11.5", + "phpunit/phpunit": "^10.5.35|^11.3.6", + "predis/predis": "^2.3", + "resend/resend-php": "^0.10.0", + "symfony/cache": "^7.0.3", + "symfony/http-client": "^7.0.3", + "symfony/psr-http-message-bridge": "^7.0.3", + "symfony/translation": "^7.0.3" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", - "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.235.5).", - "brianium/paratest": "Required to run tests in parallel (^6.0).", - "doctrine/dbal": "Required to rename columns and drop SQLite columns (^3.5.1).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", "ext-apcu": "Required to use the APC cache driver.", "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", @@ -2219,42 +1881,45 @@ "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", - "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0).", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", - "guzzlehttp/guzzle": "Required to use the HTTP Client and the ping methods on schedules (^7.5).", "laravel/tinker": "Required to use the tinker console command (^2.0).", - "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.0).", - "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.0).", - "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.3).", - "league/flysystem-read-only": "Required to use read-only disks (^3.3)", - "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.0).", - "mockery/mockery": "Required to use mocking (^1.5.1).", - "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).", - "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).", - "phpunit/phpunit": "Required to use assertions and run tests (^9.5.8|^10.0.7).", - "predis/predis": "Required to use the predis connector (^2.0.2).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5|^11.0).", + "predis/predis": "Required to use the predis connector (^2.3).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^6.2).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^6.2).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^6.2).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^6.2).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^6.2).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0)." + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.0)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "10.x-dev" + "dev-master": "11.x-dev" } }, "autoload": { "files": [ + "src/Illuminate/Collections/functions.php", "src/Illuminate/Collections/helpers.php", "src/Illuminate/Events/functions.php", "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], "psr-4": { @@ -2286,25 +1951,25 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-11-26T15:32:57+00:00" + "time": "2025-01-02T20:10:21+00:00" }, { "name": "laravel/prompts", - "version": "v0.1.25", + "version": "v0.3.2", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95" + "reference": "0e0535747c6b8d6d10adca8b68293cf4517abb0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/7b4029a84c37cb2725fc7f011586e2997040bc95", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95", + "url": "https://api.github.com/repos/laravel/prompts/zipball/0e0535747c6b8d6d10adca8b68293cf4517abb0f", + "reference": "0e0535747c6b8d6d10adca8b68293cf4517abb0f", "shasum": "" }, "require": { + "composer-runtime-api": "^2.2", "ext-mbstring": "*", - "illuminate/collections": "^10.0|^11.0", "php": "^8.1", "symfony/console": "^6.2|^7.0" }, @@ -2313,8 +1978,9 @@ "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { + "illuminate/collections": "^10.0|^11.0", "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3", + "pestphp/pest": "^2.3|^3.4", "phpstan/phpstan": "^1.11", "phpstan/phpstan-mockery": "^1.1" }, @@ -2324,7 +1990,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "0.1.x-dev" + "dev-main": "0.3.x-dev" } }, "autoload": { @@ -2342,38 +2008,38 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.1.25" + "source": "https://github.com/laravel/prompts/tree/v0.3.2" }, - "time": "2024-08-12T22:06:33+00:00" + "time": "2024-11-12T14:59:47+00:00" }, { "name": "laravel/serializable-closure", - "version": "v1.3.7", + "version": "v2.0.1", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d" + "reference": "613b2d4998f85564d40497e05e89cb6d9bd1cbe8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/4f48ade902b94323ca3be7646db16209ec76be3d", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/613b2d4998f85564d40497e05e89cb6d9bd1cbe8", + "reference": "613b2d4998f85564d40497e05e89cb6d9bd1cbe8", "shasum": "" }, "require": { - "php": "^7.3|^8.0" + "php": "^8.1" }, "require-dev": { - "illuminate/support": "^8.0|^9.0|^10.0|^11.0", - "nesbot/carbon": "^2.61|^3.0", - "pestphp/pest": "^1.21.3", - "phpstan/phpstan": "^1.8.2", - "symfony/var-dumper": "^5.4.11|^6.2.0|^7.0.0" + "illuminate/support": "^10.0|^11.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { @@ -2405,7 +2071,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2024-11-14T18:34:49+00:00" + "time": "2024-12-16T15:26:28+00:00" }, { "name": "laravel/socialite", @@ -3207,6 +2873,180 @@ }, "time": "2024-12-11T05:05:52+00:00" }, + { + "name": "league/uri", + "version": "7.5.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "81fb5145d2644324614cc532b28efd0215bda430" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", + "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.5", + "php": "^8.1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-components": "Needed to easily manipulate URI objects components", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:40:02+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.5.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-factory": "^1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common interfaces and classes for URI representation and interaction", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:18:47+00:00" + }, { "name": "masterminds/html5", "version": "2.9.0", @@ -3445,42 +3285,41 @@ }, { "name": "nesbot/carbon", - "version": "2.72.6", + "version": "3.8.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5" + "reference": "129700ed449b1f02d70272d2ac802357c8c30c58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1e9d50601e7035a4c61441a208cb5bed73e108c5", - "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/129700ed449b1f02d70272d2ac802357c8c30c58", + "reference": "129700ed449b1f02d70272d2ac802357c8c30c58", "shasum": "" }, "require": { - "carbonphp/carbon-doctrine-types": "*", + "carbonphp/carbon-doctrine-types": "<100.0", "ext-json": "*", - "php": "^7.1.8 || ^8.0", + "php": "^8.1", "psr/clock": "^1.0", + "symfony/clock": "^6.3 || ^7.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php80": "^1.16", - "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0" + "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0" }, "provide": { "psr/clock-implementation": "1.0" }, "require-dev": { - "doctrine/dbal": "^2.0 || ^3.1.4 || ^4.0", - "doctrine/orm": "^2.7 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.0", - "kylekatarnls/multi-tester": "^2.0", - "ondrejmirtes/better-reflection": "<6", - "phpmd/phpmd": "^2.9", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.99 || ^1.7.14", - "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6", - "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20", - "squizlabs/php_codesniffer": "^3.4" + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.57.2", + "kylekatarnls/multi-tester": "^2.5.3", + "ondrejmirtes/better-reflection": "^6.25.0.4", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.11.2", + "phpunit/phpunit": "^10.5.20", + "squizlabs/php_codesniffer": "^3.9.0" }, "bin": [ "bin/carbon" @@ -3548,7 +3387,7 @@ "type": "tidelift" } ], - "time": "2024-12-27T09:28:11+00:00" + "time": "2024-12-27T09:25:35+00:00" }, { "name": "nette/schema", @@ -3758,32 +3597,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v1.17.0", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301" + "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/5369ef84d8142c1d87e4ec278711d4ece3cbf301", - "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/52915afe6a1044e8b9cee1bcff836fb63acf9cda", + "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": "^8.1", - "symfony/console": "^6.4.15" + "php": "^8.2", + "symfony/console": "^7.1.8" }, "require-dev": { - "illuminate/console": "^10.48.24", - "illuminate/support": "^10.48.24", + "illuminate/console": "^11.33.2", "laravel/pint": "^1.18.2", + "mockery/mockery": "^1.6.12", "pestphp/pest": "^2.36.0", - "pestphp/pest-plugin-mock": "2.0.0", "phpstan/phpstan": "^1.12.11", "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^6.4.15", + "symfony/var-dumper": "^7.1.8", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3792,6 +3630,9 @@ "providers": [ "Termwind\\Laravel\\TermwindServiceProvider" ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -3823,7 +3664,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v1.17.0" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.0" }, "funding": [ { @@ -3839,7 +3680,7 @@ "type": "github" } ], - "time": "2024-11-21T10:36:35+00:00" + "time": "2024-11-21T10:39:51+00:00" }, { "name": "onelogin/php-saml", @@ -4320,55 +4161,6 @@ ], "time": "2024-11-21T20:00:02+00:00" }, - { - "name": "psr/cache", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" - }, - "time": "2021-02-03T23:26:27+00:00" - }, { "name": "psr/clock", "version": "1.0.0", @@ -5560,49 +5352,34 @@ "time": "2024-12-12T16:45:37+00:00" }, { - "name": "ssddanbrown/symfony-mailer", - "version": "6.4.x-dev", + "name": "symfony/clock", + "version": "v7.2.0", "source": { "type": "git", - "url": "https://github.com/ssddanbrown/symfony-mailer.git", - "reference": "0497d6eb2734fe22b9550f88ae6526611c9df7ae" + "url": "https://github.com/symfony/clock.git", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ssddanbrown/symfony-mailer/zipball/0497d6eb2734fe22b9550f88ae6526611c9df7ae", - "reference": "0497d6eb2734fe22b9550f88ae6526611c9df7ae", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", "shasum": "" }, "require": { - "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.1", - "psr/event-dispatcher": "^1", - "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/mime": "^6.2|^7.0", - "symfony/service-contracts": "^2.5|^3" + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" }, - "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", - "symfony/messenger": "<6.2", - "symfony/mime": "<6.2", - "symfony/twig-bridge": "<6.2.1" + "provide": { + "psr/clock-implementation": "1.0" }, - "replace": { - "symfony/mailer": "^6.0" - }, - "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/messenger": "^6.2|^7.0", - "symfony/twig-bridge": "^6.2|^7.0" - }, - "default-branch": true, "type": "library", "autoload": { + "files": [ + "Resources/now.php" + ], "psr-4": { - "Symfony\\Component\\Mailer\\": "" + "Symfony\\Component\\Clock\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -5614,68 +5391,82 @@ ], "authors": [ { - "name": "Dan Brown", - "homepage": "https://danb.me" - }, - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Helps sending emails", + "description": "Decouples applications from the system clock", "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], "support": { - "source": "https://github.com/ssddanbrown/symfony-mailer/tree/6.4" + "source": "https://github.com/symfony/clock/tree/v7.2.0" }, - "time": "2024-03-17T16:25:21+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/console", - "version": "v6.4.17", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "799445db3f15768ecc382ac5699e6da0520a0a04" + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/799445db3f15768ecc382ac5699e6da0520a0a04", - "reference": "799445db3f15768ecc382ac5699e6da0520a0a04", + "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" + "symfony/string": "^6.4|^7.0" }, "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -5709,7 +5500,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.17" + "source": "https://github.com/symfony/console/tree/v7.2.1" }, "funding": [ { @@ -5725,7 +5516,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T12:07:30+00:00" + "time": "2024-12-11T03:49:26+00:00" }, { "name": "symfony/css-selector", @@ -5861,22 +5652,22 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.17", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "37ad2380e8c1a8cf62a1200a5c10080b679b446c" + "reference": "6150b89186573046167796fa5f3f76601d5145f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/37ad2380e8c1a8cf62a1200a5c10080b679b446c", - "reference": "37ad2380e8c1a8cf62a1200a5c10080b679b446c", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/6150b89186573046167796fa5f3f76601d5145f8", + "reference": "6150b89186573046167796fa5f3f76601d5145f8", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/var-dumper": "^6.4|^7.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", @@ -5885,7 +5676,7 @@ "require-dev": { "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^5.4|^6.0|^7.0" + "symfony/serializer": "^6.4|^7.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -5916,7 +5707,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.17" + "source": "https://github.com/symfony/error-handler/tree/v7.2.1" }, "funding": [ { @@ -5932,7 +5723,7 @@ "type": "tidelift" } ], - "time": "2024-12-06T13:30:51+00:00" + "time": "2024-12-07T08:50:44+00:00" }, { "name": "symfony/event-dispatcher", @@ -6092,23 +5883,23 @@ }, { "name": "symfony/finder", - "version": "v6.4.17", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7" + "reference": "87a71856f2f56e4100373e92529eed3171695cfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", + "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb", + "reference": "87a71856f2f56e4100373e92529eed3171695cfb", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.0|^7.0" + "symfony/filesystem": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -6136,7 +5927,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.17" + "source": "https://github.com/symfony/finder/tree/v7.2.2" }, "funding": [ { @@ -6152,40 +5943,41 @@ "type": "tidelift" } ], - "time": "2024-12-29T13:51:37+00:00" + "time": "2024-12-30T19:00:17+00:00" }, { "name": "symfony/http-foundation", - "version": "v6.4.16", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57" + "reference": "62d1a43796ca3fea3f83a8470dfe63a4af3bc588" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/431771b7a6f662f1575b3cfc8fd7617aa9864d57", - "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/62d1a43796ca3fea3f83a8470dfe63a4af3bc588", + "reference": "62d1a43796ca3fea3f83a8470dfe63a4af3bc588", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-mbstring": "~1.1", "symfony/polyfill-php83": "^1.27" }, "conflict": { + "doctrine/dbal": "<3.6", "symfony/cache": "<6.4.12|>=7.0,<7.1.5" }, "require-dev": { - "doctrine/dbal": "^2.13.1|^3|^4", + "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", "symfony/cache": "^6.4.12|^7.1.5", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/rate-limiter": "^5.4|^6.0|^7.0" + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -6213,7 +6005,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.16" + "source": "https://github.com/symfony/http-foundation/tree/v7.2.2" }, "funding": [ { @@ -6229,77 +6021,77 @@ "type": "tidelift" } ], - "time": "2024-11-13T18:58:10+00:00" + "time": "2024-12-30T19:00:17+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.17", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "c5647393c5ce11833d13e4b70fff4b571d4ac710" + "reference": "3c432966bd8c7ec7429663105f5a02d7e75b4306" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c5647393c5ce11833d13e4b70fff4b571d4ac710", - "reference": "c5647393c5ce11833d13e4b70fff4b571d4ac710", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3c432966bd8c7ec7429663105f5a02d7e75b4306", + "reference": "3c432966bd8c7ec7429663105f5a02d7e75b4306", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<5.4", - "symfony/cache": "<5.4", - "symfony/config": "<6.1", - "symfony/console": "<5.4", + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<5.4", - "symfony/form": "<5.4", - "symfony/http-client": "<5.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<5.4", - "symfony/messenger": "<5.4", - "symfony/translation": "<5.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<5.4", + "symfony/twig-bridge": "<6.4", "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.3", - "twig/twig": "<2.13" + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^5.4|^6.0|^7.0", - "symfony/clock": "^6.2|^7.0", - "symfony/config": "^6.1|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", - "symfony/dom-crawler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/property-access": "^5.4.5|^6.0.5|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.4|^7.0.4", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^7.1", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^7.1", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.4|^7.0", - "symfony/var-exporter": "^6.2|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "twig/twig": "^3.12" }, "type": "library", "autoload": { @@ -6327,7 +6119,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.17" + "source": "https://github.com/symfony/http-kernel/tree/v7.2.2" }, "funding": [ { @@ -6343,25 +6135,104 @@ "type": "tidelift" } ], - "time": "2024-12-31T14:49:31+00:00" + "time": "2024-12-31T14:59:40+00:00" }, { - "name": "symfony/mime", - "version": "v6.4.17", + "name": "symfony/mailer", + "version": "v7.2.0", "source": { "type": "git", - "url": "https://github.com/symfony/mime.git", - "reference": "ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232" + "url": "https://github.com/symfony/mailer.git", + "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232", - "reference": "ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232", + "url": "https://api.github.com/repos/symfony/mailer/zipball/e4d358702fb66e4c8a2af08e90e7271a62de39cc", + "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/mime": "^7.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-25T15:21:05+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "7f9617fcf15cb61be30f8b252695ed5e2bfac283" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/7f9617fcf15cb61be30f8b252695ed5e2bfac283", + "reference": "7f9617fcf15cb61be30f8b252695ed5e2bfac283", + "shasum": "" + }, + "require": { + "php": ">=8.2", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -6369,17 +6240,17 @@ "egulias/email-validator": "~3.0.0", "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", - "symfony/mailer": "<5.4", + "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.4|^7.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", "symfony/serializer": "^6.4.3|^7.0.3" }, "type": "library", @@ -6412,7 +6283,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.17" + "source": "https://github.com/symfony/mime/tree/v7.2.1" }, "funding": [ { @@ -6428,7 +6299,7 @@ "type": "tidelift" } ], - "time": "2024-12-02T11:09:41+00:00" + "time": "2024-12-07T08:50:44+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7068,20 +6939,20 @@ }, { "name": "symfony/process", - "version": "v6.4.15", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "3cb242f059c14ae08591c5c4087d1fe443564392" + "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/3cb242f059c14ae08591c5c4087d1fe443564392", - "reference": "3cb242f059c14ae08591c5c4087d1fe443564392", + "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", + "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -7109,7 +6980,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.15" + "source": "https://github.com/symfony/process/tree/v7.2.0" }, "funding": [ { @@ -7125,40 +6996,38 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:19:14+00:00" + "time": "2024-11-06T14:24:19+00:00" }, { "name": "symfony/routing", - "version": "v6.4.16", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220" + "reference": "e10a2450fa957af6c448b9b93c9010a4e4c0725e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/91e02e606b4b705c2f4fb42f7e7708b7923a3220", - "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220", + "url": "https://api.github.com/repos/symfony/routing/zipball/e10a2450fa957af6c448b9b93c9010a4e4c0725e", + "reference": "e10a2450fa957af6c448b9b93c9010a4e4c0725e", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { - "doctrine/annotations": "<1.12", - "symfony/config": "<6.2", - "symfony/dependency-injection": "<5.4", - "symfony/yaml": "<5.4" + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" }, "require-dev": { - "doctrine/annotations": "^1.12|^2", "psr/log": "^1|^2|^3", - "symfony/config": "^6.2|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7192,7 +7061,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.16" + "source": "https://github.com/symfony/routing/tree/v7.2.0" }, "funding": [ { @@ -7208,7 +7077,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T15:31:34+00:00" + "time": "2024-11-25T11:08:51+00:00" }, { "name": "symfony/service-contracts", @@ -7382,33 +7251,33 @@ }, { "name": "symfony/translation", - "version": "v6.4.13", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66" + "reference": "e2674a30132b7cc4d74540d6c2573aa363f05923" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/bee9bfabfa8b4045a66bf82520e492cddbaffa66", - "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66", + "url": "https://api.github.com/repos/symfony/translation/zipball/e2674a30132b7cc4d74540d6c2573aa363f05923", + "reference": "e2674a30132b7cc4d74540d6c2573aa363f05923", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/translation-contracts": "^2.5|^3.0" }, "conflict": { - "symfony/config": "<5.4", - "symfony/console": "<5.4", - "symfony/dependency-injection": "<5.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", + "symfony/http-kernel": "<6.4", "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<5.4", - "symfony/yaml": "<5.4" + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "symfony/translation-implementation": "2.3|3.0" @@ -7416,17 +7285,17 @@ "require-dev": { "nikic/php-parser": "^4.18|^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/routing": "^6.4|^7.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/yaml": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7457,7 +7326,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.13" + "source": "https://github.com/symfony/translation/tree/v7.2.2" }, "funding": [ { @@ -7473,7 +7342,7 @@ "type": "tidelift" } ], - "time": "2024-09-27T18:14:25+00:00" + "time": "2024-12-07T08:18:10+00:00" }, { "name": "symfony/translation-contracts", @@ -7555,24 +7424,24 @@ }, { "name": "symfony/uid", - "version": "v6.4.13", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007" + "reference": "2d294d0c48df244c71c105a169d0190bfb080426" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/18eb207f0436a993fffbdd811b5b8fa35fa5e007", - "reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007", + "url": "https://api.github.com/repos/symfony/uid/zipball/2d294d0c48df244c71c105a169d0190bfb080426", + "reference": "2d294d0c48df244c71c105a169d0190bfb080426", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7609,7 +7478,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v6.4.13" + "source": "https://github.com/symfony/uid/tree/v7.2.0" }, "funding": [ { @@ -7625,38 +7494,36 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.15", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80" + "reference": "c6a22929407dec8765d6e2b6ff85b800b245879c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80", - "reference": "38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c6a22929407dec8765d6e2b6ff85b800b245879c", + "reference": "c6a22929407dec8765d6e2b6ff85b800b245879c", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { "ext-iconv": "*", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^6.3|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/uid": "^5.4|^6.0|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/console": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "twig/twig": "^3.12" }, "bin": [ "Resources/bin/var-dump-server" @@ -7694,7 +7561,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.15" + "source": "https://github.com/symfony/var-dumper/tree/v7.2.0" }, "funding": [ { @@ -7710,7 +7577,7 @@ "type": "tidelift" } ], - "time": "2024-11-08T15:28:48+00:00" + "time": "2024-11-08T15:48:14+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8484,40 +8351,38 @@ }, { "name": "nunomaduro/collision", - "version": "v7.11.0", + "version": "v8.5.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "994ea93df5d4132f69d3f1bd74730509df6e8a05" + "reference": "f5c101b929c958e849a633283adff296ed5f38f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/994ea93df5d4132f69d3f1bd74730509df6e8a05", - "reference": "994ea93df5d4132f69d3f1bd74730509df6e8a05", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f5c101b929c958e849a633283adff296ed5f38f5", + "reference": "f5c101b929c958e849a633283adff296ed5f38f5", "shasum": "" }, "require": { "filp/whoops": "^2.16.0", - "nunomaduro/termwind": "^1.15.1", - "php": "^8.1.0", - "symfony/console": "^6.4.12" + "nunomaduro/termwind": "^2.1.0", + "php": "^8.2.0", + "symfony/console": "^7.1.5" }, "conflict": { - "laravel/framework": ">=11.0.0" + "laravel/framework": "<11.0.0 || >=12.0.0", + "phpunit/phpunit": "<10.5.1 || >=12.0.0" }, "require-dev": { - "brianium/paratest": "^7.3.1", - "laravel/framework": "^10.48.22", + "larastan/larastan": "^2.9.8", + "laravel/framework": "^11.28.0", "laravel/pint": "^1.18.1", "laravel/sail": "^1.36.0", - "laravel/sanctum": "^3.3.3", + "laravel/sanctum": "^4.0.3", "laravel/tinker": "^2.10.0", - "nunomaduro/larastan": "^2.9.8", - "orchestra/testbench-core": "^8.28.3", - "pestphp/pest": "^2.35.1", - "phpunit/phpunit": "^10.5.36", - "sebastian/environment": "^6.1.0", - "spatie/laravel-ignition": "^2.8.0" + "orchestra/testbench-core": "^9.5.3", + "pestphp/pest": "^2.36.0 || ^3.4.0", + "sebastian/environment": "^6.1.0 || ^7.2.0" }, "type": "library", "extra": { @@ -8525,6 +8390,9 @@ "providers": [ "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" } }, "autoload": { @@ -8576,7 +8444,7 @@ "type": "patreon" } ], - "time": "2024-10-15T15:12:40+00:00" + "time": "2024-10-15T16:06:32+00:00" }, { "name": "phar-io/manifest", @@ -10423,9 +10291,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "ssddanbrown/symfony-mailer": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/database/migrations/2022_04_25_140741_update_polymorphic_types.php b/database/migrations/2022_04_25_140741_update_polymorphic_types.php index 00e1e0688..f201d41cc 100644 --- a/database/migrations/2022_04_25_140741_update_polymorphic_types.php +++ b/database/migrations/2022_04_25_140741_update_polymorphic_types.php @@ -8,7 +8,7 @@ return new class extends Migration /** * Mapping of old polymorphic types to new simpler values. */ - protected $changeMap = [ + protected array $changeMap = [ 'BookStack\\Bookshelf' => 'bookshelf', 'BookStack\\Book' => 'book', 'BookStack\\Chapter' => 'chapter', @@ -18,7 +18,7 @@ return new class extends Migration /** * Mapping of tables and columns that contain polymorphic types. */ - protected $columnsByTable = [ + protected array $columnsByTable = [ 'activities' => 'entity_type', 'comments' => 'entity_type', 'deletions' => 'deletable_type', diff --git a/public/index.php b/public/index.php index fdf6e720f..2b5c57b8f 100644 --- a/public/index.php +++ b/public/index.php @@ -5,45 +5,16 @@ use Illuminate\Contracts\Http\Kernel; define('LARAVEL_START', microtime(true)); -/* -|-------------------------------------------------------------------------- -| Check If The Application Is Under Maintenance -|-------------------------------------------------------------------------- -| -| If the application is in maintenance / demo mode via the "down" command -| we will load this file so that any pre-rendered content can be shown -| instead of starting the framework, which could cause an exception. -| -*/ - +// Determine if the application is in maintenance mode... if (file_exists(__DIR__ . '/../storage/framework/maintenance.php')) { require __DIR__ . '/../storage/framework/maintenance.php'; } -/* -|-------------------------------------------------------------------------- -| Register The Auto Loader -|-------------------------------------------------------------------------- -| -| Composer provides a convenient, automatically generated class loader for -| this application. We just need to utilize it! We'll simply require it -| into the script here so we don't need to manually load our classes. -| -*/ - +// Register the Composer autoloader... require __DIR__ . '/../vendor/autoload.php'; -/* -|-------------------------------------------------------------------------- -| Run The Application -|-------------------------------------------------------------------------- -| -| Once we have the application, we can handle the incoming request using -| the application's HTTP kernel. Then, we will send the response back -| to this client's browser, allowing them to enjoy our application. -| -*/ +// Run the application $app = require_once __DIR__ . '/../bootstrap/app.php'; $app->alias('request', Request::class); From 5bf75786c6326f4c2ef5df577a1062aeacce7bff Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Jan 2025 13:22:49 +0000 Subject: [PATCH 03/56] Framework: Fixed Laravel 11 upgrade test issues, updated phpstan - Fixed failing tests due to Laravel 11 changes - Updated phpstan to 3.x branch - Removed some seemingly redundant comment code, which was triggering phpstan. --- app/Activity/Models/Comment.php | 17 ------ composer.json | 2 +- composer.lock | 60 +++++++++---------- .../2015_08_31_175240_add_search_indexes.php | 12 +--- .../2015_12_05_145049_fulltext_weighting.php | 12 +--- ...03_19_091553_create_search_index_table.php | 12 +--- phpstan.neon.dist | 4 +- tests/Entity/PageTest.php | 2 +- 8 files changed, 42 insertions(+), 79 deletions(-) diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 7d1c54646..d0385d396 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -26,7 +26,6 @@ class Comment extends Model implements Loggable use HasCreatorAndUpdater; protected $fillable = ['parent_id']; - protected $appends = ['created', 'updated']; /** * Get the entity that this comment belongs to. @@ -54,22 +53,6 @@ class Comment extends Model implements Loggable return $this->updated_at->timestamp > $this->created_at->timestamp; } - /** - * Get created date as a relative diff. - */ - public function getCreatedAttribute(): string - { - return $this->created_at->diffForHumans(); - } - - /** - * Get updated date as a relative diff. - */ - public function getUpdatedAttribute(): string - { - return $this->updated_at->diffForHumans(); - } - public function logDescriptor(): string { return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})"; diff --git a/composer.json b/composer.json index 58b89fcce..2c3c04732 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "itsgoingd/clockwork": "^5.1", "mockery/mockery": "^1.5", "nunomaduro/collision": "^8.1", - "larastan/larastan": "^2.7", + "larastan/larastan": "^v3.0", "phpunit/phpunit": "^10.0", "squizlabs/php_codesniffer": "^3.7", "ssddanbrown/asserthtml": "^3.0" diff --git a/composer.lock b/composer.lock index 1d512f851..c7c4018a1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "518176ac5bb608061e0f74b06fdae582", + "content-hash": "36bd84db9a3fd3e801fbd8375b91e0e5", "packages": [ { "name": "aws/aws-crt-php", @@ -8115,40 +8115,40 @@ }, { "name": "larastan/larastan", - "version": "v2.9.12", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "19012b39fbe4dede43dbe0c126d9681827a5e908" + "reference": "b2e24e1605cff1d1097ccb6fb8af3bbd1dfe1c6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/19012b39fbe4dede43dbe0c126d9681827a5e908", - "reference": "19012b39fbe4dede43dbe0c126d9681827a5e908", + "url": "https://api.github.com/repos/larastan/larastan/zipball/b2e24e1605cff1d1097ccb6fb8af3bbd1dfe1c6f", + "reference": "b2e24e1605cff1d1097ccb6fb8af3bbd1dfe1c6f", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/console": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/container": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/contracts": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/database": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/http": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/pipeline": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/support": "^9.52.16 || ^10.28.0 || ^11.16", - "php": "^8.0.2", + "illuminate/console": "^11.15.0", + "illuminate/container": "^11.15.0", + "illuminate/contracts": "^11.15.0", + "illuminate/database": "^11.15.0", + "illuminate/http": "^11.15.0", + "illuminate/pipeline": "^11.15.0", + "illuminate/support": "^11.15.0", + "php": "^8.2", "phpmyadmin/sql-parser": "^5.9.0", - "phpstan/phpstan": "^1.12.11" + "phpstan/phpstan": "^2.0.2" }, "require-dev": { "doctrine/coding-standard": "^12.0", - "laravel/framework": "^9.52.16 || ^10.28.0 || ^11.16", - "mockery/mockery": "^1.5.1", - "nikic/php-parser": "^4.19.1", - "orchestra/canvas": "^7.11.1 || ^8.11.0 || ^9.0.2", - "orchestra/testbench-core": "^7.33.0 || ^8.13.0 || ^9.0.9", - "phpstan/phpstan-deprecation-rules": "^1.2", - "phpunit/phpunit": "^9.6.13 || ^10.5.16" + "laravel/framework": "^11.15.0", + "mockery/mockery": "^1.6", + "nikic/php-parser": "^5.3", + "orchestra/canvas": "^v9.1.3", + "orchestra/testbench-core": "^9.5.2", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpunit/phpunit": "^10.5.16" }, "suggest": { "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" @@ -8183,7 +8183,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan wrapper for Laravel", "keywords": [ "PHPStan", "code analyse", @@ -8196,7 +8196,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v2.9.12" + "source": "https://github.com/larastan/larastan/tree/v3.0.2" }, "funding": [ { @@ -8204,7 +8204,7 @@ "type": "github" } ], - "time": "2024-11-26T23:09:02+00:00" + "time": "2024-11-26T23:15:21+00:00" }, { "name": "mockery/mockery", @@ -8653,20 +8653,20 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.15", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1" + "reference": "cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c91d4e8bc056f46cf653656e6f71004b254574d1", - "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7", + "reference": "cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -8707,7 +8707,7 @@ "type": "github" } ], - "time": "2025-01-05T16:40:22+00:00" + "time": "2025-01-05T16:43:48+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/database/migrations/2015_08_31_175240_add_search_indexes.php b/database/migrations/2015_08_31_175240_add_search_indexes.php index 4d58d9409..13141698a 100644 --- a/database/migrations/2015_08_31_175240_add_search_indexes.php +++ b/database/migrations/2015_08_31_175240_add_search_indexes.php @@ -26,25 +26,19 @@ return new class extends Migration */ public function down(): void { - $sm = Schema::getConnection()->getDoctrineSchemaManager(); - $prefix = DB::getTablePrefix(); - $pages = $sm->introspectTable($prefix . 'pages'); - $books = $sm->introspectTable($prefix . 'books'); - $chapters = $sm->introspectTable($prefix . 'chapters'); - - if ($pages->hasIndex('search')) { + if (Schema::hasIndex('pages', 'search')) { Schema::table('pages', function (Blueprint $table) { $table->dropIndex('search'); }); } - if ($books->hasIndex('search')) { + if (Schema::hasIndex('books', 'search')) { Schema::table('books', function (Blueprint $table) { $table->dropIndex('search'); }); } - if ($chapters->hasIndex('search')) { + if (Schema::hasIndex('chapters', 'search')) { Schema::table('chapters', function (Blueprint $table) { $table->dropIndex('search'); }); diff --git a/database/migrations/2015_12_05_145049_fulltext_weighting.php b/database/migrations/2015_12_05_145049_fulltext_weighting.php index b20c04520..3d0d21e70 100644 --- a/database/migrations/2015_12_05_145049_fulltext_weighting.php +++ b/database/migrations/2015_12_05_145049_fulltext_weighting.php @@ -26,25 +26,19 @@ return new class extends Migration */ public function down(): void { - $sm = Schema::getConnection()->getDoctrineSchemaManager(); - $prefix = DB::getTablePrefix(); - $pages = $sm->introspectTable($prefix . 'pages'); - $books = $sm->introspectTable($prefix . 'books'); - $chapters = $sm->introspectTable($prefix . 'chapters'); - - if ($pages->hasIndex('name_search')) { + if (Schema::hasIndex('pages', 'name_search')) { Schema::table('pages', function (Blueprint $table) { $table->dropIndex('name_search'); }); } - if ($books->hasIndex('name_search')) { + if (Schema::hasIndex('books', 'name_search')) { Schema::table('books', function (Blueprint $table) { $table->dropIndex('name_search'); }); } - if ($chapters->hasIndex('name_search')) { + if (Schema::hasIndex('chapters', 'name_search')) { Schema::table('chapters', function (Blueprint $table) { $table->dropIndex('name_search'); }); diff --git a/database/migrations/2017_03_19_091553_create_search_index_table.php b/database/migrations/2017_03_19_091553_create_search_index_table.php index 56281741e..7f96aca6b 100644 --- a/database/migrations/2017_03_19_091553_create_search_index_table.php +++ b/database/migrations/2017_03_19_091553_create_search_index_table.php @@ -25,27 +25,21 @@ return new class extends Migration $table->index('score'); }); - $sm = Schema::getConnection()->getDoctrineSchemaManager(); - $prefix = DB::getTablePrefix(); - $pages = $sm->introspectTable($prefix . 'pages'); - $books = $sm->introspectTable($prefix . 'books'); - $chapters = $sm->introspectTable($prefix . 'chapters'); - - if ($pages->hasIndex('search')) { + if (Schema::hasIndex('pages', 'search')) { Schema::table('pages', function (Blueprint $table) { $table->dropIndex('search'); $table->dropIndex('name_search'); }); } - if ($books->hasIndex('search')) { + if (Schema::hasIndex('books', 'search')) { Schema::table('books', function (Blueprint $table) { $table->dropIndex('search'); $table->dropIndex('name_search'); }); } - if ($chapters->hasIndex('search')) { + if (Schema::hasIndex('chapters', 'search')) { Schema::table('chapters', function (Blueprint $table) { $table->dropIndex('search'); $table->dropIndex('name_search'); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index aa2ad3d9e..0f2021383 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -21,6 +21,4 @@ parameters: excludePaths: - ./Config/**/*.php - - ./dev/**/*.php - - checkMissingIterableValueType: false \ No newline at end of file + - ./dev/**/*.php \ No newline at end of file diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index b96d455eb..deeead099 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -300,7 +300,7 @@ class PageTest extends TestCase ]); $resp = $this->asAdmin()->get('/pages/recently-updated'); - $this->withHtml($resp)->assertElementContains('.entity-list .page:nth-child(1)', 'Updated 1 second ago by ' . $user->name); + $this->withHtml($resp)->assertElementContains('.entity-list .page:nth-child(1)', 'Updated 0 seconds ago by ' . $user->name); } public function test_recently_updated_pages_view_shows_parent_chain() From ad8bc5fe21ddccc243f6a4ef4e87bbd28668c423 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Jan 2025 13:50:01 +0000 Subject: [PATCH 04/56] Framework: Updated phpunit to 11, updated migration test php versions --- .github/workflows/test-migrations.yml | 2 +- .github/workflows/test-php.yml | 2 +- composer.json | 4 +- composer.lock | 556 ++++++++++++++------------ 4 files changed, 314 insertions(+), 250 deletions(-) diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 2d6d280b2..23e58a772 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4'] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 277af9070..64132f673 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -13,7 +13,7 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: php: ['8.2', '8.3', '8.4'] diff --git a/composer.json b/composer.json index 2c3c04732..9ffded1c5 100644 --- a/composer.json +++ b/composer.json @@ -46,9 +46,9 @@ "mockery/mockery": "^1.5", "nunomaduro/collision": "^8.1", "larastan/larastan": "^v3.0", - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^11.5", "squizlabs/php_codesniffer": "^3.7", - "ssddanbrown/asserthtml": "^3.0" + "ssddanbrown/asserthtml": "^3.1" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index c7c4018a1..5d2b38215 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "36bd84db9a3fd3e801fbd8375b91e0e5", + "content-hash": "f8d7d0be63e355bf8d39ebf2ced165b7", "packages": [ { "name": "aws/aws-crt-php", @@ -5520,20 +5520,20 @@ }, { "name": "symfony/css-selector", - "version": "v6.4.13", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e" + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/cb23e97813c5837a041b73a6d63a9ddff0778f5e", - "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -5565,7 +5565,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.13" + "source": "https://github.com/symfony/css-selector/tree/v7.2.0" }, "funding": [ { @@ -5581,7 +5581,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/deprecation-contracts", @@ -8711,35 +8711,35 @@ }, { "name": "phpunit/php-code-coverage", - "version": "10.1.16", + "version": "11.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + "reference": "418c59fd080954f8c4aa5631d9502ecda2387118" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", - "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/418c59fd080954f8c4aa5631d9502ecda2387118", + "reference": "418c59fd080954f8c4aa5631d9502ecda2387118", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=8.1", - "phpunit/php-file-iterator": "^4.1.0", - "phpunit/php-text-template": "^3.0.1", - "sebastian/code-unit-reverse-lookup": "^3.0.0", - "sebastian/complexity": "^3.2.0", - "sebastian/environment": "^6.1.0", - "sebastian/lines-of-code": "^2.0.2", - "sebastian/version": "^4.0.1", + "nikic/php-parser": "^5.3.1", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^10.1" + "phpunit/phpunit": "^11.5.0" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -8748,7 +8748,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.1.x-dev" + "dev-main": "11.0.x-dev" } }, "autoload": { @@ -8777,7 +8777,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.8" }, "funding": [ { @@ -8785,32 +8785,32 @@ "type": "github" } ], - "time": "2024-08-22T04:31:57+00:00" + "time": "2024-12-11T12:34:27+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "4.1.0", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", - "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -8838,7 +8838,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" }, "funding": [ { @@ -8846,28 +8846,28 @@ "type": "github" } ], - "time": "2023-08-31T06:24:48+00:00" + "time": "2024-08-27T05:02:59+00:00" }, { "name": "phpunit/php-invoker", - "version": "4.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", - "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "suggest": { "ext-pcntl": "*" @@ -8875,7 +8875,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -8901,7 +8901,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" }, "funding": [ { @@ -8909,32 +8910,32 @@ "type": "github" } ], - "time": "2023-02-03T06:56:09+00:00" + "time": "2024-07-03T05:07:44+00:00" }, { "name": "phpunit/php-text-template", - "version": "3.0.1", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", - "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -8961,7 +8962,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" }, "funding": [ { @@ -8969,32 +8970,32 @@ "type": "github" } ], - "time": "2023-08-31T14:07:24+00:00" + "time": "2024-07-03T05:08:43+00:00" }, { "name": "phpunit/php-timer", - "version": "6.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", - "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9020,7 +9021,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" }, "funding": [ { @@ -9028,20 +9030,20 @@ "type": "github" } ], - "time": "2023-02-03T06:57:52+00:00" + "time": "2024-07-03T05:09:35+00:00" }, { "name": "phpunit/phpunit", - "version": "10.5.40", + "version": "11.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "e6ddda95af52f69c1e0c7b4f977cccb58048798c" + "reference": "153d0531b9f7e883c5053160cad6dd5ac28140b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e6ddda95af52f69c1e0c7b4f977cccb58048798c", - "reference": "e6ddda95af52f69c1e0c7b4f977cccb58048798c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/153d0531b9f7e883c5053160cad6dd5ac28140b3", + "reference": "153d0531b9f7e883c5053160cad6dd5ac28140b3", "shasum": "" }, "require": { @@ -9054,23 +9056,23 @@ "myclabs/deep-copy": "^1.12.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.1", - "phpunit/php-code-coverage": "^10.1.16", - "phpunit/php-file-iterator": "^4.1.0", - "phpunit/php-invoker": "^4.0.0", - "phpunit/php-text-template": "^3.0.1", - "phpunit/php-timer": "^6.0.0", - "sebastian/cli-parser": "^2.0.1", - "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.3", - "sebastian/diff": "^5.1.1", - "sebastian/environment": "^6.1.0", - "sebastian/exporter": "^5.1.2", - "sebastian/global-state": "^6.0.2", - "sebastian/object-enumerator": "^5.0.0", - "sebastian/recursion-context": "^5.0.0", - "sebastian/type": "^4.0.0", - "sebastian/version": "^4.0.1" + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.8", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.2", + "sebastian/comparator": "^6.2.1", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.0", + "sebastian/exporter": "^6.3.0", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.0", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" }, "suggest": { "ext-soap": "To be able to generate mocks based on WSDL files" @@ -9081,7 +9083,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.5-dev" + "dev-main": "11.5-dev" } }, "autoload": { @@ -9113,7 +9115,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.40" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.2" }, "funding": [ { @@ -9129,32 +9131,32 @@ "type": "tidelift" } ], - "time": "2024-12-21T05:49:06+00:00" + "time": "2024-12-21T05:51:08+00:00" }, { "name": "sebastian/cli-parser", - "version": "2.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", - "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -9178,7 +9180,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" }, "funding": [ { @@ -9186,32 +9188,32 @@ "type": "github" } ], - "time": "2024-03-02T07:12:49+00:00" + "time": "2024-07-03T04:41:36+00:00" }, { "name": "sebastian/code-unit", - "version": "2.0.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", - "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", + "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -9234,7 +9236,8 @@ "homepage": "https://github.com/sebastianbergmann/code-unit", "support": { "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.2" }, "funding": [ { @@ -9242,32 +9245,32 @@ "type": "github" } ], - "time": "2023-02-03T06:58:43+00:00" + "time": "2024-12-12T09:59:06+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", - "version": "3.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", - "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9289,7 +9292,8 @@ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", "support": { "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" }, "funding": [ { @@ -9297,36 +9301,39 @@ "type": "github" } ], - "time": "2023-02-03T06:59:15+00:00" + "time": "2024-07-03T04:45:54+00:00" }, { "name": "sebastian/comparator", - "version": "5.0.3", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e" + "reference": "d4e47a769525c4dd38cea90e5dcd435ddbbc7115" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/d4e47a769525c4dd38cea90e5dcd435ddbbc7115", + "reference": "d4e47a769525c4dd38cea90e5dcd435ddbbc7115", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.1", - "sebastian/diff": "^5.0", - "sebastian/exporter": "^5.0" + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.2-dev" } }, "autoload": { @@ -9366,7 +9373,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.0" }, "funding": [ { @@ -9374,33 +9381,33 @@ "type": "github" } ], - "time": "2024-10-18T14:56:07+00:00" + "time": "2025-01-06T10:28:19+00:00" }, { "name": "sebastian/complexity", - "version": "3.2.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "68ff824baeae169ec9f2137158ee529584553799" + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", - "reference": "68ff824baeae169ec9f2137158ee529584553799", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=8.1" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.2-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9424,7 +9431,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" }, "funding": [ { @@ -9432,33 +9439,33 @@ "type": "github" } ], - "time": "2023-12-21T08:37:17+00:00" + "time": "2024-07-03T04:49:50+00:00" }, { "name": "sebastian/diff", - "version": "5.1.1", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", - "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "symfony/process": "^6.4" + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9491,7 +9498,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" }, "funding": [ { @@ -9499,27 +9506,27 @@ "type": "github" } ], - "time": "2024-03-02T07:15:17+00:00" + "time": "2024-07-03T04:53:05+00:00" }, { "name": "sebastian/environment", - "version": "6.1.0", + "version": "7.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", - "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "suggest": { "ext-posix": "*" @@ -9527,7 +9534,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.1-dev" + "dev-main": "7.2-dev" } }, "autoload": { @@ -9555,7 +9562,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" }, "funding": [ { @@ -9563,34 +9570,34 @@ "type": "github" } ], - "time": "2024-03-23T08:47:14+00:00" + "time": "2024-07-03T04:54:44+00:00" }, { "name": "sebastian/exporter", - "version": "5.1.2", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf" + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/955288482d97c19a372d3f31006ab3f37da47adf", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.1", - "sebastian/recursion-context": "^5.0" + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "6.1-dev" } }, "autoload": { @@ -9633,7 +9640,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0" }, "funding": [ { @@ -9641,35 +9648,35 @@ "type": "github" } ], - "time": "2024-03-02T07:17:12+00:00" + "time": "2024-12-05T09:17:50+00:00" }, { "name": "sebastian/global-state", - "version": "6.0.2", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", - "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", "shasum": "" }, "require": { - "php": ">=8.1", - "sebastian/object-reflector": "^3.0", - "sebastian/recursion-context": "^5.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9695,7 +9702,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" }, "funding": [ { @@ -9703,33 +9710,33 @@ "type": "github" } ], - "time": "2024-03-02T07:19:19+00:00" + "time": "2024-07-03T04:57:36+00:00" }, { "name": "sebastian/lines-of-code", - "version": "2.0.2", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", - "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=8.1" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -9753,7 +9760,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" }, "funding": [ { @@ -9761,34 +9768,34 @@ "type": "github" } ], - "time": "2023-12-21T08:38:20+00:00" + "time": "2024-07-03T04:58:38+00:00" }, { "name": "sebastian/object-enumerator", - "version": "5.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", - "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": ">=8.1", - "sebastian/object-reflector": "^3.0", - "sebastian/recursion-context": "^5.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9810,7 +9817,8 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { @@ -9818,32 +9826,32 @@ "type": "github" } ], - "time": "2023-02-03T07:08:32+00:00" + "time": "2024-07-03T05:00:13+00:00" }, { "name": "sebastian/object-reflector", - "version": "3.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", - "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9865,7 +9873,8 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { @@ -9873,32 +9882,32 @@ "type": "github" } ], - "time": "2023-02-03T07:06:18+00:00" + "time": "2024-07-03T05:01:32+00:00" }, { "name": "sebastian/recursion-context", - "version": "5.0.0", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712" + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9928,7 +9937,8 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" }, "funding": [ { @@ -9936,32 +9946,32 @@ "type": "github" } ], - "time": "2023-02-03T07:05:40+00:00" + "time": "2024-07-03T05:10:34+00:00" }, { "name": "sebastian/type", - "version": "4.0.0", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", - "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/461b9c5da241511a2a0e8f240814fb23ce5c0aac", + "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -9984,7 +9994,8 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.0" }, "funding": [ { @@ -9992,29 +10003,29 @@ "type": "github" } ], - "time": "2023-02-03T07:10:45+00:00" + "time": "2024-09-17T13:12:04+00:00" }, { "name": "sebastian/version", - "version": "4.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", - "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -10037,7 +10048,8 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { @@ -10045,7 +10057,7 @@ "type": "github" } ], - "time": "2023-02-07T11:34:05+00:00" + "time": "2024-10-09T05:16:32+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -10129,11 +10141,11 @@ }, { "name": "ssddanbrown/asserthtml", - "version": "v3.0.1", + "version": "v3.1.0", "source": { "type": "git", "url": "https://codeberg.org/danb/asserthtml", - "reference": "31b3035b5533ae80ab16c80d3774dd840dbfd079" + "reference": "cf8206171d667d43e1bdde17d67191f30e95c8a0" }, "dist": { "type": "zip", @@ -10143,12 +10155,12 @@ "ext-dom": "*", "ext-json": "*", "php": ">=8.1", - "phpunit/phpunit": "^10.0", - "symfony/css-selector": "^6.0", - "symfony/dom-crawler": "^6.0" + "phpunit/phpunit": "^10.0|^11.0", + "symfony/css-selector": "^6.0|^7.0", + "symfony/dom-crawler": "^6.0|^7.0" }, "require-dev": { - "phpstan/phpstan": "^1.10" + "phpstan/phpstan": "^2.0" }, "type": "library", "autoload": { @@ -10169,30 +10181,82 @@ ], "description": "HTML Content Assertions for PHPUnit", "homepage": "https://codeberg.org/danb/asserthtml", - "time": "2024-12-28T15:15:02+00:00" + "time": "2025-01-11T13:35:55+00:00" }, { - "name": "symfony/dom-crawler", - "version": "v6.4.16", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/symfony/dom-crawler.git", - "reference": "4304e6ad5c894a9c72831ad459f627bfd35d766d" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4304e6ad5c894a9c72831ad459f627bfd35d766d", - "reference": "4304e6ad5c894a9c72831ad459f627bfd35d766d", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "b176e1f1f550ef44c94eb971bf92488de08f7c6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b176e1f1f550ef44c94eb971bf92488de08f7c6b", + "reference": "b176e1f1f550ef44c94eb971bf92488de08f7c6b", "shasum": "" }, "require": { "masterminds/html5": "^2.6", - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.0" }, "require-dev": { - "symfony/css-selector": "^5.4|^6.0|^7.0" + "symfony/css-selector": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -10220,7 +10284,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.16" + "source": "https://github.com/symfony/dom-crawler/tree/v7.2.0" }, "funding": [ { @@ -10236,7 +10300,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T15:06:22+00:00" + "time": "2024-11-13T16:15:23+00:00" }, { "name": "theseer/tokenizer", From dbda82ef9275fcb791a27cd329d8383c6a676d17 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Jan 2025 15:05:10 +0000 Subject: [PATCH 05/56] Framework: Re-add updated patched symfony-mailer https://github.com/ssddanbrown/symfony-mailer/commit/e9de8dccd76a63fc23475016e6574da6f5f12a2 --- composer.json | 3 +- composer.lock | 159 ++++++++++++++++++++++++-------------------------- 2 files changed, 79 insertions(+), 83 deletions(-) diff --git a/composer.json b/composer.json index 9ffded1c5..6fcf26aaa 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,8 @@ "socialiteproviders/microsoft-azure": "^5.1", "socialiteproviders/okta": "^4.2", "socialiteproviders/twitch": "^5.3", - "ssddanbrown/htmldiff": "^1.0.2" + "ssddanbrown/htmldiff": "^1.0.2", + "ssddanbrown/symfony-mailer": "7.2.x-dev" }, "require-dev": { "fakerphp/faker": "^1.21", diff --git a/composer.lock b/composer.lock index 5d2b38215..1f3808cdc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f8d7d0be63e355bf8d39ebf2ced165b7", + "content-hash": "8e9b6e95d9222289ce51646e27c1f404", "packages": [ { "name": "aws/aws-crt-php", @@ -5351,6 +5351,79 @@ ], "time": "2024-12-12T16:45:37+00:00" }, + { + "name": "ssddanbrown/symfony-mailer", + "version": "7.2.x-dev", + "source": { + "type": "git", + "url": "https://github.com/ssddanbrown/symfony-mailer.git", + "reference": "e9de8dccd76a63fc23475016e6574da6f5f12a2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ssddanbrown/symfony-mailer/zipball/e9de8dccd76a63fc23475016e6574da6f5f12a2d", + "reference": "e9de8dccd76a63fc23475016e6574da6f5f12a2d", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/mime": "^7.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "replace": { + "symfony/mailer": "^7.0" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dan Brown", + "homepage": "https://danb.me" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/ssddanbrown/symfony-mailer/tree/7.2" + }, + "time": "2025-01-11T14:57:07+00:00" + }, { "name": "symfony/clock", "version": "v7.2.0", @@ -6137,86 +6210,6 @@ ], "time": "2024-12-31T14:59:40+00:00" }, - { - "name": "symfony/mailer", - "version": "v7.2.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/mailer.git", - "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e4d358702fb66e4c8a2af08e90e7271a62de39cc", - "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc", - "shasum": "" - }, - "require": { - "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.2", - "psr/event-dispatcher": "^1", - "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/mime": "^7.2", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/messenger": "<6.4", - "symfony/mime": "<6.4", - "symfony/twig-bridge": "<6.4" - }, - "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/twig-bridge": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Mailer\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Helps sending emails", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/mailer/tree/v7.2.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-11-25T15:21:05+00:00" - }, { "name": "symfony/mime", "version": "v7.2.1", @@ -10355,7 +10348,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "ssddanbrown/symfony-mailer": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { From ee88832f1a468af9f930807071da3a206eeb91c8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 13 Jan 2025 13:26:04 +0000 Subject: [PATCH 06/56] Updated translations with latest Crowdin changes (#5399) --- lang/bn/auth.php | 18 +++++++++--------- lang/fr/activities.php | 12 ++++++------ lang/fr/editor.php | 4 ++-- lang/fr/entities.php | 42 ++++++++++++++++++++--------------------- lang/fr/errors.php | 20 ++++++++++---------- lang/fr/settings.php | 2 +- lang/fr/validation.php | 8 ++++---- lang/ko/activities.php | 6 +++--- lang/ko/common.php | 14 +++++++------- lang/ko/components.php | 2 +- lang/ko/entities.php | 26 ++++++++++++------------- lang/ko/errors.php | 6 +++--- lang/ko/settings.php | 36 +++++++++++++++++------------------ lang/ko/validation.php | 4 ++-- lang/pt_BR/entities.php | 38 ++++++++++++++++++------------------- 15 files changed, 119 insertions(+), 119 deletions(-) diff --git a/lang/bn/auth.php b/lang/bn/auth.php index aca585962..06ed376a7 100644 --- a/lang/bn/auth.php +++ b/lang/bn/auth.php @@ -14,16 +14,16 @@ return [ 'log_in' => 'লগ ইন করুন', 'log_in_with' => ':socialDriver দ্বারা লগইন করুন', 'sign_up_with' => ':socialDriver দ্বারা নিবন্ধিত হোন', - 'logout' => 'Logout', + 'logout' => 'লগআউট', - 'name' => 'Name', - 'username' => 'Username', - 'email' => 'Email', - 'password' => 'Password', - 'password_confirm' => 'Confirm Password', - 'password_hint' => 'Must be at least 8 characters', - 'forgot_password' => 'Forgot Password?', - 'remember_me' => 'Remember Me', + 'name' => 'নাম', + 'username' => 'ব্যবহারকারী', + 'email' => 'ই-মেইল', + 'password' => 'পাসওয়ার্ড', + 'password_confirm' => 'পাসওয়ার্ডের পুনরাবৃত্তি', + 'password_hint' => 'ন্যূনতম ৮ অক্ষরের হতে হবে', + 'forgot_password' => 'পাসওয়ার্ড ভুলে গেছেন?', + 'remember_me' => 'লগইন স্থায়িত্ব ধরে রাখুন', 'ldap_email_hint' => 'Please enter an email to use for this account.', 'create_account' => 'Create Account', 'already_have_account' => 'Already have an account?', diff --git a/lang/fr/activities.php b/lang/fr/activities.php index 871b88648..34ec83ec3 100644 --- a/lang/fr/activities.php +++ b/lang/fr/activities.php @@ -85,12 +85,12 @@ return [ 'webhook_delete_notification' => 'Webhook supprimé avec succès', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => 'import créé', + 'import_create_notification' => 'Importation envoyée avec succès', + 'import_run' => 'importation mise à jour', + 'import_run_notification' => 'Contenu importé avec succès', + 'import_delete' => 'import supprimé', + 'import_delete_notification' => 'Importation supprimée avec succès', // Users 'user_create' => 'utilisateur créé', diff --git a/lang/fr/editor.php b/lang/fr/editor.php index 11edae10c..bdef1e8b1 100644 --- a/lang/fr/editor.php +++ b/lang/fr/editor.php @@ -163,8 +163,8 @@ return [ 'about' => 'À propos de l\'éditeur', 'about_title' => 'À propos de l\'éditeur WYSIWYG', 'editor_license' => 'Licence d\'éditeur et droit d\'auteur', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Cet éditeur est construit comme un fork de :lexicalLink qui est distribué sous licence MIT.', + 'editor_lexical_license_link' => 'Vous trouverez ici tous les détails de la licence.', 'editor_tiny_license' => 'Cet éditeur est construit en utilisant :tinyLink qui est fourni sous la licence MIT.', 'editor_tiny_license_link' => 'Vous trouverez ici les détails sur les droits d\'auteur et les licences de TinyMCE.', 'save_continue' => 'Enregistrer et continuer', diff --git a/lang/fr/entities.php b/lang/fr/entities.php index 6581587d4..2382a1d6c 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -39,30 +39,30 @@ return [ 'export_pdf' => 'Fichier PDF', 'export_text' => 'Document texte', 'export_md' => 'Fichiers Markdown', - 'export_zip' => 'Portable ZIP', + 'export_zip' => 'Export ZIP', 'default_template' => 'Modèle de page par défaut', 'default_template_explain' => 'Sélectionnez un modèle de page qui sera utilisé comme contenu par défaut pour les nouvelles pages créées dans cet élément. Gardez à l\'esprit que le modèle ne sera utilisé que si le créateur de la page a accès au modèle sélectionné.', 'default_template_select' => 'Sélectionnez un modèle de page', - 'import' => 'Import', - 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + 'import' => 'Importation', + 'import_validate' => 'Valider l\'import', + 'import_desc' => 'Importez des livres, des chapitres et des pages à l\'aide d\'un export zip à partir de la même instance ou d\'une instance différente. Sélectionnez un fichier ZIP pour continuer. Une fois le fichier téléchargé et validé, vous pourrez configurer et confirmer l\'importation dans la vue suivante.', + 'import_zip_select' => 'Sélectionnez le fichier ZIP à télécharger', + 'import_zip_validation_errors' => 'Des erreurs ont été détectées lors de la validation du fichier ZIP fourni:', + 'import_pending' => 'Importations en attente', + 'import_pending_none' => 'Aucune importation n\'a été commencée.', + 'import_continue' => 'Continuer l\'importation', + 'import_continue_desc' => 'Examinez le contenu à importer à partir du fichier ZIP téléchargé. Lorsque vous êtes prêt, lancez l\'importation pour ajouter son contenu à ce système. Le fichier d\'importation ZIP téléchargé sera automatiquement supprimé si l\'importation est réussie.', + 'import_details' => 'Détails de l\'importation', + 'import_run' => 'Exécuter Importation', + 'import_size' => ':size taille du ZIP d\'import', + 'import_uploaded_at' => ':relativeTime téléchargé', + 'import_uploaded_by' => 'Téléchargé par', + 'import_location' => 'Emplacement de l\'importation', + 'import_location_desc' => 'Sélectionnez un emplacement cible pour votre contenu importé. Vous aurez besoin des autorisations appropriées pour créer dans l\'emplacement que vous choisissez.', + 'import_delete_confirm' => 'Êtes-vous sûr de vouloir supprimer cette importation ?', + 'import_delete_desc' => 'Ceci supprimera le fichier ZIP importé et ne pourra pas être annulé.', + 'import_errors' => 'Erreurs d\'importation', + 'import_errors_desc' => 'Les erreurs suivantes se sont produites lors de la tentative d\'importation :', // Permissions and restrictions 'permissions' => 'Autorisations', diff --git a/lang/fr/errors.php b/lang/fr/errors.php index d89926dac..94d21e1dd 100644 --- a/lang/fr/errors.php +++ b/lang/fr/errors.php @@ -106,16 +106,16 @@ return [ 'back_soon' => 'Nous serons bientôt de retour.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'Impossible de lire le fichier ZIP.', + 'import_zip_cant_decode_data' => 'Impossible de trouver et de décoder le contenu ZIP data.json.', + 'import_zip_no_data' => 'Les données du fichier ZIP n\'ont pas de livre, de chapitre ou de page attendus.', + 'import_validation_failed' => 'L\'importation du ZIP n\'a pas été validée avec les erreurs :', + 'import_zip_failed_notification' => 'Impossible d\'importer le fichier ZIP.', + 'import_perms_books' => 'Vous n\'avez pas les permissions requises pour créer des livres.', + 'import_perms_chapters' => 'Vous n\'avez pas les permissions requises pour créer des chapitres.', + 'import_perms_pages' => 'Vous n\'avez pas les permissions requises pour créer des pages.', + 'import_perms_images' => 'Vous n\'avez pas les permissions requises pour créer des images.', + 'import_perms_attachments' => 'Vous n\'avez pas les permissions requises pour créer des pièces jointes.', // API errors 'api_no_authorization_found' => 'Aucun jeton d\'autorisation trouvé pour la demande', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index d113e89ab..58bce55e0 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -162,7 +162,7 @@ return [ 'role_access_api' => 'Accès à l\'API du système', 'role_manage_settings' => 'Gérer les préférences de l\'application', 'role_export_content' => 'Exporter le contenu', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Importer le contenu', 'role_editor_change' => 'Changer l\'éditeur de page', 'role_notifications' => 'Recevoir et gérer les notifications', 'role_asset' => 'Permissions des ressources', diff --git a/lang/fr/validation.php b/lang/fr/validation.php index ed16776e7..7db0493a1 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -105,10 +105,10 @@ return [ 'url' => ':attribute a un format invalide.', 'uploaded' => 'Le fichier n\'a pas pu être envoyé. Le serveur peut ne pas accepter des fichiers de cette taille.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => 'L\'attribut :attribute doit référencer un fichier dans le ZIP.', + 'zip_file_mime' => ':attribute doit référencer un fichier de type :validTypes, trouvé :foundType.', + 'zip_model_expected' => 'Objet de données attendu, mais ":type" trouvé.', + 'zip_unique' => 'L\'attribut :attribute doit être unique pour le type d\'objet dans le ZIP.', // Custom validation lines 'custom' => [ diff --git a/lang/ko/activities.php b/lang/ko/activities.php index c3f35e443..4e733b7b5 100644 --- a/lang/ko/activities.php +++ b/lang/ko/activities.php @@ -50,7 +50,7 @@ return [ 'bookshelf_delete_notification' => '책장이 성공적으로 삭제되었습니다.', // Revisions - 'revision_restore' => '복구된 리비전', + 'revision_restore' => '복구된 버전', 'revision_delete' => '버전 삭제', 'revision_delete_notification' => '버전 삭제 성공', @@ -74,13 +74,13 @@ return [ // Settings 'settings_update' => '설정 변경', 'settings_update_notification' => '설졍 변경 성공', - 'maintenance_action_run' => '유지 관리 작업 실행', + 'maintenance_action_run' => '유지관리 작업 실행', // Webhooks 'webhook_create' => '웹 훅 생성', 'webhook_create_notification' => '웹 훅 생성함', 'webhook_update' => '웹 훅 수정하기', - 'webhook_update_notification' => '웹훅 설정이 수정되었습니다.', + 'webhook_update_notification' => '웹 훅 설정이 수정되었습니다.', 'webhook_delete' => '웹 훅 지우기', 'webhook_delete_notification' => '웹 훅 삭제함', diff --git a/lang/ko/common.php b/lang/ko/common.php index a0751d4c0..00caf4f54 100644 --- a/lang/ko/common.php +++ b/lang/ko/common.php @@ -18,12 +18,12 @@ return [ // Form Labels 'name' => '이름', 'description' => '설명', - 'role' => '권한', + 'role' => '역할', 'cover_image' => '대표 이미지', - 'cover_image_description' => '이 이미지는 필요에 따라 다양한 시나리오에서 사용자 인터페이스에 맞게 크기가 조정되거나 잘려질 수 있기 때문에 실제 표시되는 크기가 다를 수는 있지만 대략 440x250px 이어야 합니다.', + 'cover_image_description' => '이 이미지는 대략 440x250px가 되어야 하지만, 필요에 따라 다양한 시나리오에서 사용자 인터페이스에 맞게 유연하게 크기 조절 및 자르기가 가능하므로 실제로 표시되는 크기는 다를 수 있습니다.', // Actions - 'actions' => '활동', + 'actions' => '동작', 'view' => '보기', 'view_all' => '모두 보기', 'new' => '신규', @@ -35,9 +35,9 @@ return [ 'copy' => '복사', 'reply' => '답글', 'delete' => '삭제', - 'delete_confirm' => '삭제 요청 확인', + 'delete_confirm' => '삭제', 'search' => '검색', - 'search_clear' => '검색 지우기', + 'search_clear' => '검색창 비우기', 'reset' => '리셋', 'remove' => '제거', 'add' => '추가', @@ -73,8 +73,8 @@ return [ 'toggle_details' => '내용 보기', 'toggle_thumbnails' => '썸네일 보기', 'details' => '정보', - 'grid_view' => '격자 보기', - 'list_view' => '목록 보기', + 'grid_view' => '격자 형식으로 보기', + 'list_view' => '리스트 형식으로 보기', 'default' => '기본 설정', 'breadcrumb' => '탐색 경로', 'status' => '상태', diff --git a/lang/ko/components.php b/lang/ko/components.php index cbe4d44c2..0c0bc2e65 100644 --- a/lang/ko/components.php +++ b/lang/ko/components.php @@ -23,7 +23,7 @@ return [ 'image_load_more' => '더 보기', 'image_image_name' => '이미지 이름', 'image_delete_used' => '이 이미지는 다음 문서들이 쓰고 있습니다.', - 'image_delete_confirm_text' => '이 이미지를 지울 건가요?', + 'image_delete_confirm_text' => '이 이미지를 정말 삭제하시겠습니까?', 'image_select_image' => '이미지 선택', 'image_dropzone' => '여기에 이미지를 드롭하거나 여기를 클릭하세요. 이미지를 올릴 수 있습니다.', 'image_dropzone_drop' => '업로드 할 이미지 파일을 여기에 놓으세요.', diff --git a/lang/ko/entities.php b/lang/ko/entities.php index cfb905a8f..7402056ef 100644 --- a/lang/ko/entities.php +++ b/lang/ko/entities.php @@ -39,22 +39,22 @@ return [ 'export_pdf' => 'PDF 파일', 'export_text' => '일반 텍스트 파일', 'export_md' => '마크다운 파일', - 'export_zip' => '압축 파일', + 'export_zip' => '컨텐츠 ZIP 파일', 'default_template' => '기본 페이지 템플릿', 'default_template_explain' => '이 항목 내에서 생성되는 모든 페이지의 기본 콘텐츠로 사용할 페이지 템플릿을 지정합니다. 페이지 작성자가 선택한 템플릿 페이지를 볼 수 있는 권한이 있는 경우에만 이 항목이 사용된다는 점을 유의하세요.', 'default_template_select' => '템플릿 페이지 선택', - 'import' => '압축 파일 가져오기', - 'import_validate' => '압축 파일 검증하기', + 'import' => '컨텐츠 ZIP 파일 가져오기', + 'import_validate' => '컨텐츠 ZIP 파일 검증하기', 'import_desc' => '같은 인스턴스나 다른 인스턴스에서 휴대용 zip 내보내기를 사용하여 책, 장 및 페이지를 가져옵니다. 진행하려면 ZIP 파일을 선택합니다. 파일을 업로드하고 검증한 후 다음 보기에서 가져오기를 구성하고 확인할 수 있습니다.', 'import_zip_select' => '업로드할 휴대용 압축 파일 선택', - 'import_zip_validation_errors' => '제공된 Portable ZIP 파일을 검증하는 동안 오류가 감지되었습니다.', - 'import_pending' => 'Portable ZIP 파일 가져오기가 일시정지 되었습니다.', + 'import_zip_validation_errors' => '컨텐츠 ZIP 파일을 검증하는 동안 오류가 감지되었습니다.', + 'import_pending' => '컨텐츠 ZIP 파일 가져오기가 일시정지 되었습니다.', 'import_pending_none' => 'Portable ZIP 파일 가져오기가 시작되지 않았습니다.', - 'import_continue' => 'Portable ZIP 가져오기 계속하기', - 'import_continue_desc' => '업로드된 ZIP 파일에서 가져올 내용을 검토합니다. 준비가 되면 가져오기를 실행하여 이 시스템에 내용을 추가합니다. 업로드된 ZIP 가져오기 파일은 가져오기가 성공하면 자동으로 제거됩니다.', - 'import_details' => '압축된 ZIP 파일 상세', + 'import_continue' => '컨텐츠 ZIP 파일 가져오기가 시작되지 않았습니다.', + 'import_continue_desc' => '업로드된 컨텐츠 ZIP 파일에서 가져올 내용을 검토합니다. 준비가 되면 가져오기를 실행하여 이 시스템에 내용을 추가합니다. 업로드된 ZIP 가져오기 파일은 가져오기가 성공하면 자동으로 제거됩니다.', + 'import_details' => '컨텐츠 ZIP 파일 상세', 'import_run' => '가져오기 실행', - 'import_size' => '압축된 ZIP 파일 사이즈', + 'import_size' => '컨텐츠 ZIP 파일 사이즈', 'import_uploaded_at' => '업로드되었습니다. :relativeTime', 'import_uploaded_by' => '업로드되었습니다.', 'import_location' => '가져올 경로', @@ -87,7 +87,7 @@ return [ 'search_terms' => '용어 검색', 'search_content_type' => '형식', 'search_exact_matches' => '정확히 일치', - 'search_tags' => '꼬리표 일치', + 'search_tags' => '태그 검색', 'search_options' => '선택', 'search_viewed_by_me' => '내가 읽음', 'search_not_viewed_by_me' => '내가 읽지 않음', @@ -162,7 +162,7 @@ return [ 'books_empty_create_page' => '문서 만들기', 'books_empty_sort_current_book' => '현재 책 정렬', 'books_empty_add_chapter' => '챕터 만들기', - 'books_permissions_active' => '책 권한 허용함', + 'books_permissions_active' => '책 권한 적용됨', 'books_search_this' => '이 책에서 검색', 'books_navigation' => '목차', 'books_sort' => '책 내용 정렬', @@ -332,7 +332,7 @@ return [ 'tags_index_desc' => '태그를 시스템 내의 콘텐츠에 적용하여 유연한 형태의 분류를 적용할 수 있습니다. 태그는 키와 값을 모두 가질 수 있으며 값은 선택 사항입니다. 태그가 적용되면 태그 이름과 값을 사용하여 콘텐츠를 쿼리할 수 있습니다.', 'tag_name' => '꼬리표 이름', 'tag_value' => '리스트 값 (선택 사항)', - 'tags_explain' => "꼬리표로 문서를 분류하세요.", + 'tags_explain' => "문서를 더 잘 분류하려면 태그를 추가하세요.\n태그에 값을 할당하여 더욱 체계적으로 구성할 수 있습니다.", 'tags_add' => '꼬리표 추가', 'tags_remove' => '꼬리표 삭제', 'tags_usages' => '모든 꼬리표', @@ -344,7 +344,7 @@ return [ 'tags_all_values' => '모든 값', 'tags_view_tags' => '꼬리표 보기', 'tags_view_existing_tags' => '사용 중인 꼬리표 보기', - 'tags_list_empty_hint' => '꼬리표는 에디터 사이드바나 책, 챕터 또는 책꽂이 정보 편집에서 지정할 수 있습니다.', + 'tags_list_empty_hint' => '태그는 에디터 사이드바나 책, 챕터 또는 책꽂이 정보 편집에서 지정할 수 있습니다.', 'attachments' => '첨부 파일', 'attachments_explain' => '파일이나 링크를 첨부하세요. 정보 탭에 나타납니다.', 'attachments_explain_instant_save' => '여기에서 바꾼 내용은 바로 적용합니다.', diff --git a/lang/ko/errors.php b/lang/ko/errors.php index ea1fd7d61..9639a5036 100644 --- a/lang/ko/errors.php +++ b/lang/ko/errors.php @@ -108,9 +108,9 @@ return [ // Import 'import_zip_cant_read' => 'ZIP 파일을 읽을 수 없습니다.', 'import_zip_cant_decode_data' => 'ZIP data.json 콘텐츠를 찾아서 디코딩할 수 없습니다.', - 'import_zip_no_data' => 'ZIP 파일 데이터에는 예상되는 책, 장 또는 페이지 콘텐츠가 없습니다.', - 'import_validation_failed' => 'ZIP 파일을 가져오려다 실패했습니다. 이유:', - 'import_zip_failed_notification' => 'ZIP 파일을 가져오지 못했습니다.', + 'import_zip_no_data' => '컨텐츠 ZIP 파일 데이터에 데이터가 비어있습니다.', + 'import_validation_failed' => '컨텐츠 ZIP 파일을 가져오려다 실패했습니다. 이유:', + 'import_zip_failed_notification' => '컨텐츠 ZIP 파일을 가져오지 못했습니다.', 'import_perms_books' => '책을 만드는 데 필요한 권한이 없습니다.', 'import_perms_chapters' => '챕터를 만드는 데 필요한 권한이 없습니다.', 'import_perms_pages' => '페이지를 만드는 데 필요한 권한이 없습니다.', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index c2f8fd780..169f7970f 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -69,8 +69,8 @@ return [ 'reg_enable_external_warning' => '외부 시스템이 LDAP나 SAML 인증이 활성화되어 있다면 설정과 관계없이 인증을 성공할 때 없는 계정을 만듭니다.', 'reg_email_confirmation' => '메일 주소 확인', 'reg_email_confirmation_toggle' => '메일 주소 확인', - 'reg_confirm_email_desc' => '도메인 차단을 활성화하면 설정과 관계없이 메일 주소 확인이 필요합니다.', - 'reg_confirm_restrict_domain' => '도메인 차단', + 'reg_confirm_email_desc' => '도메인 제한을 활성화하면 설정과 관계없이 메일 주소 확인이 필요합니다.', + 'reg_confirm_restrict_domain' => '도메인 제한', 'reg_confirm_restrict_domain_desc' => '가입을 차단할 도메인을 쉼표로 구분하여 입력하세요. 사용자가 메일 주소 확인에 성공하면 메일 주소를 바꿀 수 있습니다.', 'reg_confirm_restrict_domain_placeholder' => '차단한 도메인 없음', @@ -80,13 +80,13 @@ return [ 'maint_image_cleanup_desc' => '중복인 이미지를 찾습니다. 실행하기 전에 이미지를 백업하세요.', 'maint_delete_images_only_in_revisions' => '지난 버전에만 있는 이미지 지우기', 'maint_image_cleanup_run' => '실행', - 'maint_image_cleanup_warning' => '이미지 :count개를 지울 건가요?', + 'maint_image_cleanup_warning' => '이미지 :count개를 지우시겠습니까?', 'maint_image_cleanup_success' => '이미지 :count개 삭제함', 'maint_image_cleanup_nothing_found' => '삭제한 것 없음', 'maint_send_test_email' => '테스트 메일 보내기', 'maint_send_test_email_desc' => '메일 주소로 테스트 메일을 전송합니다.', 'maint_send_test_email_run' => '테스트 메일 보내기', - 'maint_send_test_email_success' => ':address로 보냈습니다.', + 'maint_send_test_email_success' => ':address 계정으로 이메일을 보냈습니다.', 'maint_send_test_email_mail_subject' => '테스트 메일', 'maint_send_test_email_mail_greeting' => '메일을 수신했습니다.', 'maint_send_test_email_mail_text' => '메일을 정상적으로 수신했습니다.', @@ -108,7 +108,7 @@ return [ 'recycle_bin_restore' => '복원', 'recycle_bin_contents_empty' => '휴지통이 비었습니다.', 'recycle_bin_empty' => '비우기', - 'recycle_bin_empty_confirm' => '휴지통을 비울 건가요?', + 'recycle_bin_empty_confirm' => '이렇게 하면 각 항목에 포함된 콘텐츠를 포함하여 휴지통에 있는 모든 항목이 영구적으로 삭제됩니다. 휴지통을 비우시겠습니까?', 'recycle_bin_destroy_confirm' => '이 작업을 수행하면 이 항목이 아래에 나열된 모든 하위 요소와 함께 시스템에서 영구적으로 삭제되며, 복원할 수 없습니다. 이 항목을 영구 삭제하시겠어요?', 'recycle_bin_destroy_list' => '영구 삭제함', 'recycle_bin_restore_list' => '복원함', @@ -119,8 +119,8 @@ return [ 'recycle_bin_restore_notification' => ':count항목 복원함', // Audit Log - 'audit' => '추적 기록', - 'audit_desc' => '시스템에서 추적한 작업입니다. 권한 필터가 작동하지 않습니다.', + 'audit' => '활동 기록', + 'audit_desc' => '이 활동 로그는 시스템에서 추적된 활동 목록을 표시합니다. 이 목록은 권한 필터가 적용되는 시스템의 유사한 활동 목록과 달리 필터링되지 않습니다.', 'audit_event_filter' => '이벤트 필터', 'audit_event_filter_no_filter' => '필터 없음', 'audit_deleted_item' => '삭제한 항목', @@ -134,8 +134,8 @@ return [ 'audit_date_to' => 'To', // Role Settings - 'roles' => '권한', - 'role_user_roles' => '사용자 권한', + 'roles' => '역할', + 'role_user_roles' => '사용자 역할', 'roles_index_desc' => '역할은 사용자를 그룹화하고 구성원에게 시스템 권한을 제공하기 위해 사용됩니다. 사용자가 여러 역할의 구성원인 경우 부여된 권한이 중첩되며 모든 권한을 상속받게 됩니다.', 'roles_x_users_assigned' => ':count 명의 사용자가 할당됨|:count 명의 사용자가 할당됨', 'roles_x_permissions_provided' => ':count 개의 권한|:count 개의 권한', @@ -147,10 +147,10 @@ return [ 'role_delete_users_assigned' => '이 권한을 가진 사용자 :userCount명에 할당할 권한을 고르세요.', 'role_delete_no_migration' => "할당하지 않음", 'role_delete_sure' => '이 권한을 지울 건가요?', - 'role_edit' => '권한 수정', - 'role_details' => '권한 정보', - 'role_name' => '권한 이름', - 'role_desc' => '설명', + 'role_edit' => '역할 수정', + 'role_details' => '역할 정보', + 'role_name' => '역할 이름', + 'role_desc' => '역할 설명', 'role_mfa_enforced' => '다중 인증 필요', 'role_external_auth_id' => '외부 인증 계정', 'role_system' => '시스템 권한', @@ -168,14 +168,14 @@ return [ 'role_asset' => '권한 항목', 'roles_system_warning' => '위 세 권한은 자신의 권한이나 다른 유저의 권한을 바꿀 수 있습니다.', 'role_asset_desc' => '책, 챕터, 문서별 권한은 이 설정에 우선합니다.', - 'role_asset_admins' => 'Admin 권한은 어디든 접근할 수 있지만 이 설정은 사용자 인터페이스에서 해당 활동을 표시할지 결정합니다.', + 'role_asset_admins' => '관리자 권한은 어디든 접근할 수 있지만 이 설정은 사용자 인터페이스에서 해당 활동을 표시할지 결정합니다.', 'role_asset_image_view_note' => '이는 이미지 관리자 내 가시성과 관련이 있습니다. 업로드된 이미지 파일의 실제 접근은 시스템의 이미지 저장 설정에 따라 달라집니다.', 'role_all' => '모든 항목', 'role_own' => '직접 만든 항목', 'role_controlled_by_asset' => '저마다 다름', 'role_save' => '저장', - 'role_users' => '이 권한을 가진 사용자들', - 'role_users_none' => '그런 사용자가 없습니다.', + 'role_users' => '이 역할을 가진 사용자들', + 'role_users_none' => '역할이 부여된 사용자가 없습니다.', // Users 'users' => '사용자', @@ -200,7 +200,7 @@ return [ 'users_delete' => '사용자 삭제', 'users_delete_named' => ':userName 삭제', 'users_delete_warning' => ':userName에 관한 데이터를 지웁니다.', - 'users_delete_confirm' => '이 사용자를 지울 건가요?', + 'users_delete_confirm' => '이 사용자를 삭제하시겠습니까?', 'users_migrate_ownership' => '소유자 바꾸기', 'users_migrate_ownership_desc' => '선택한 사용자가 소유하고 있는 모든 항목을 다른 유저가 소유하게 합니다.', 'users_none_selected' => '선택한 유저 없음', @@ -246,7 +246,7 @@ return [ 'user_api_token_updated' => ':timeAgo 전에 토큰 갱신함', 'user_api_token_delete' => '토큰 삭제', 'user_api_token_delete_warning' => '\':tokenName\'을 시스템에서 삭제합니다.', - 'user_api_token_delete_confirm' => '이 API 토큰을 지울 건가요?', + 'user_api_token_delete_confirm' => '이 API 토큰을 삭제하시겠습니까?', // Webhooks 'webhooks' => '웹 훅', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index df7667403..ef7361ff9 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -105,10 +105,10 @@ return [ 'url' => ':attribute(은)는 유효하지 않은 형식입니다.', 'uploaded' => '파일 크기가 서버에서 허용하는 수치를 넘습니다.', - 'zip_file' => ':attribute은(는) ZIP 파일 내의 파일을 참조해야 합니다.', + 'zip_file' => ':attribute은(는) 컨텐츠 ZIP 파일 내의 객체 유형에 대해 고유해야 합니다.', 'zip_file_mime' => ':attribute은(는) :validTypes, found :foundType 유형의 파일을 참조해야 합니다.', 'zip_model_expected' => '데이터 객체가 필요하지만 ":type" 타입이 발견되었습니다.', - 'zip_unique' => ':attribute은(는) ZIP 파일 내의 객체 유형에 대해 고유해야 합니다.', + 'zip_unique' => ':attribute은(는) 컨텐츠 ZIP 파일 내의 객체 유형에 대해 고유해야 합니다.', // Custom validation lines 'custom' => [ diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index fe73c35b8..f947b3040 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -87,7 +87,7 @@ return [ 'search_terms' => 'Termos da pesquisa', 'search_content_type' => 'Categoria de conteúdo', 'search_exact_matches' => 'Correspondências exatas', - 'search_tags' => 'Etiqueta de buscas', + 'search_tags' => 'Pesquisar marcadores', 'search_options' => 'Opções', 'search_viewed_by_me' => 'Visto por mim', 'search_not_viewed_by_me' => 'Não visto por mim', @@ -255,7 +255,7 @@ return [ 'pages_editor_switch_consider_following' => 'Considere o seguinte ao alterar editores:', 'pages_editor_switch_consideration_a' => 'Uma vez salva, a nova opção do editor será usada por quaisquer editores futuros, incluindo aqueles que podem não ser capazes de mudar o tipo do editor.', 'pages_editor_switch_consideration_b' => 'Isso pode levar a uma perda de detalhes e sintaxe em certas circunstâncias.', - 'pages_editor_switch_consideration_c' => 'Etiqueta ou alterações no log de mudanças, feitas desde o último salvamento, não persistem nesta alteração.', + 'pages_editor_switch_consideration_c' => 'Marcadores ou alterações no log de mudanças, feitas desde o último salvamento, não persistem nesta alteração.', 'pages_save' => 'Salvar Página', 'pages_title' => 'Título da Página', 'pages_name' => 'Nome da Página', @@ -299,9 +299,9 @@ return [ 'pages_pointer_enter_mode' => 'Entrar em modo de seleção de seção', 'pages_pointer_label' => 'Opções de Seção de Página', 'pages_pointer_permalink' => 'Seção de Página Permalink', - 'pages_pointer_include_tag' => 'Seção de Página Incluir Tag', - 'pages_pointer_toggle_link' => 'Modo permalink, pressione para mostrar a tag incluída', - 'pages_pointer_toggle_include' => 'Incluir o modo tag, pressione para mostrar permalink', + 'pages_pointer_include_tag' => 'Marcador de inclusão de seção de página', + 'pages_pointer_toggle_link' => 'Modo permalink, pressione para mostrar a marcação incluída', + 'pages_pointer_toggle_include' => 'Incluir o modo de marcação, pressione para mostrar o permalink', 'pages_permissions_active' => 'Permissões de Página Ativas', 'pages_initial_revision' => 'Publicação Inicial', 'pages_references_update_revision' => 'Atualização automática do sistema de links internos', @@ -323,18 +323,18 @@ return [ // Editor Sidebar 'toggle_sidebar' => '', - 'page_tags' => 'Tags de Página', - 'chapter_tags' => 'Tags de Capítulo', - 'book_tags' => 'Tags de Livro', - 'shelf_tags' => 'Tags de Prateleira', - 'tag' => 'Tag', - 'tags' => 'Tags', - 'tags_index_desc' => 'As tags podem ser aplicadas ao conteúdo dentro do sistema para aplicar uma forma flexível de categorização. As tags podem ter uma chave e um valor, sendo o valor opcional. Depois de aplicado, o conteúdo pode ser consultado usando o nome e o valor da tag.', - 'tag_name' => 'Nome da Tag', - 'tag_value' => 'Valor da Tag (Opcional)', - 'tags_explain' => "Adicione algumas tags para melhor categorizar seu conteúdo. \n Você pode atribuir valores às tags para uma organização mais complexa.", - 'tags_add' => 'Adicionar outra tag', - 'tags_remove' => 'Remover essa tag', + 'page_tags' => 'Marcadores de Página', + 'chapter_tags' => 'Marcadores de Capítulo', + 'book_tags' => 'Marcadores de Livro', + 'shelf_tags' => 'Marcadores de Estante', + 'tag' => 'Marcador', + 'tags' => 'Marcadores', + 'tags_index_desc' => 'Os marcadores podem ser aplicadas ao conteúdo dentro do sistema para aplicar uma forma flexível de categorização. Os marcadores podem ter uma chave e um valor, sendo o valor opcional. Depois de aplicado, o conteúdo pode ser consultado usando o nome e o valor do marcador.', + 'tag_name' => 'Nome do marcador', + 'tag_value' => 'Valor do marcador (Opcional)', + 'tags_explain' => "Adicione alguns marcadores para melhor categorizar seu conteúdo. \n Você pode atribuir valores aos marcadores para uma organização mais complexa.", + 'tags_add' => 'Adicionar outro marcador', + 'tags_remove' => 'Remover esse marcador', 'tags_usages' => 'Total de marcadores usados', 'tags_assigned_pages' => 'Atribuído às páginas', 'tags_assigned_chapters' => 'Atribuído aos Capítulos', @@ -343,8 +343,8 @@ return [ 'tags_x_unique_values' => ':count valores únicos', 'tags_all_values' => 'Todos os valores', 'tags_view_tags' => 'Ver Marcadores', - 'tags_view_existing_tags' => 'Ver tags existentes', - 'tags_list_empty_hint' => 'As tags podem ser atribuídas através da barra lateral do editor de página ou ao editar os detalhes de um livro, capítulo ou prateleira.', + 'tags_view_existing_tags' => 'Ver marcadores existentes', + 'tags_list_empty_hint' => 'Os marcadores podem ser atribuídos através da barra lateral do editor de página ou ao editar os detalhes de um livro, capítulo ou prateleira.', 'attachments' => 'Anexos', 'attachments_explain' => 'Faça o upload de alguns arquivos ou anexe links para serem exibidos na sua página. Eles estarão visíveis na barra lateral à direita.', 'attachments_explain_instant_save' => 'Mudanças são salvas instantaneamente.', From 593645acfe8521db97d7469c92546c8529703969 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 13 Jan 2025 14:30:53 +0000 Subject: [PATCH 07/56] Themes: Added route to serve public theme files Allows files to be placed within a "public" folder within a theme directory which the contents of will served by BookStack for access. - Only "web safe" content-types are provided. - A static 1 day cache time it set on served files. For #3904 --- app/App/helpers.php | 4 +-- .../Controllers/BookExportController.php | 2 +- .../Controllers/ChapterExportController.php | 2 +- .../Controllers/PageExportController.php | 2 +- app/Http/DownloadResponseFactory.php | 19 +++++++++++- .../Middleware/PreventResponseCaching.php | 14 +++++++++ app/Theming/ThemeController.php | 31 +++++++++++++++++++ app/Theming/ThemeService.php | 9 ++++++ app/Uploads/FileStorage.php | 9 +++--- app/Uploads/ImageStorageDisk.php | 9 +++--- app/Util/FilePathNormalizer.php | 17 ++++++++++ routes/web.php | 8 ++++- 12 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 app/Theming/ThemeController.php create mode 100644 app/Util/FilePathNormalizer.php diff --git a/app/App/helpers.php b/app/App/helpers.php index af6dbcfc3..941c267d6 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -1,6 +1,7 @@ queries->findVisibleBySlugOrFail($bookSlug); $zip = $builder->buildForBook($book); - return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', true); } } diff --git a/app/Exports/Controllers/ChapterExportController.php b/app/Exports/Controllers/ChapterExportController.php index de2385bb1..849024343 100644 --- a/app/Exports/Controllers/ChapterExportController.php +++ b/app/Exports/Controllers/ChapterExportController.php @@ -82,6 +82,6 @@ class ChapterExportController extends Controller $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $zip = $builder->buildForChapter($chapter); - return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', true); } } diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php index d7145411e..145dce9dd 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -86,6 +86,6 @@ class PageExportController extends Controller $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $zip = $builder->buildForPage($page); - return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', true); } } diff --git a/app/Http/DownloadResponseFactory.php b/app/Http/DownloadResponseFactory.php index d06e2bac4..01b3502d4 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -39,8 +39,9 @@ class DownloadResponseFactory * Create a response that downloads the given file via a stream. * Has the option to delete the provided file once the stream is closed. */ - public function streamedFileDirectly(string $filePath, string $fileName, int $fileSize, bool $deleteAfter = false): StreamedResponse + public function streamedFileDirectly(string $filePath, string $fileName, bool $deleteAfter = false): StreamedResponse { + $fileSize = filesize($filePath); $stream = fopen($filePath, 'r'); if ($deleteAfter) { @@ -79,6 +80,22 @@ class DownloadResponseFactory ); } + /** + * Create a response that provides the given file via a stream with detected content-type. + * Has the option to delete the provided file once the stream is closed. + */ + public function streamedFileInline(string $filePath, ?string $fileName = null): StreamedResponse + { + $fileSize = filesize($filePath); + $stream = fopen($filePath, 'r'); + + if ($fileName === null) { + $fileName = basename($filePath); + } + + return $this->streamedInline($stream, $fileName, $fileSize); + } + /** * Get the common headers to provide for a download response. */ diff --git a/app/Http/Middleware/PreventResponseCaching.php b/app/Http/Middleware/PreventResponseCaching.php index c763b5fc1..a40150444 100644 --- a/app/Http/Middleware/PreventResponseCaching.php +++ b/app/Http/Middleware/PreventResponseCaching.php @@ -7,6 +7,13 @@ use Symfony\Component\HttpFoundation\Response; class PreventResponseCaching { + /** + * Paths to ignore when preventing response caching. + */ + protected array $ignoredPathPrefixes = [ + 'theme/', + ]; + /** * Handle an incoming request. * @@ -20,6 +27,13 @@ class PreventResponseCaching /** @var Response $response */ $response = $next($request); + $path = $request->path(); + foreach ($this->ignoredPathPrefixes as $ignoredPath) { + if (str_starts_with($path, $ignoredPath)) { + return $response; + } + } + $response->headers->set('Cache-Control', 'no-cache, no-store, private'); $response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT'); diff --git a/app/Theming/ThemeController.php b/app/Theming/ThemeController.php new file mode 100644 index 000000000..1eecc6974 --- /dev/null +++ b/app/Theming/ThemeController.php @@ -0,0 +1,31 @@ +download()->streamedFileInline($filePath); + $response->setMaxAge(86400); + + return $response; + } +} diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php index 94e471217..639854d6a 100644 --- a/app/Theming/ThemeService.php +++ b/app/Theming/ThemeService.php @@ -15,6 +15,15 @@ class ThemeService */ protected array $listeners = []; + /** + * Get the currently configured theme. + * Returns an empty string if not configured. + */ + public function getTheme(): string + { + return config('view.theme') ?? ''; + } + /** * Listen to a given custom theme event, * setting up the action to be ran when the event occurs. diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php index 6e4a210a1..70040725a 100644 --- a/app/Uploads/FileStorage.php +++ b/app/Uploads/FileStorage.php @@ -3,12 +3,12 @@ namespace BookStack\Uploads; use BookStack\Exceptions\FileUploadException; +use BookStack\Util\FilePathNormalizer; use Exception; use Illuminate\Contracts\Filesystem\Filesystem as Storage; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -use League\Flysystem\WhitespacePathNormalizer; use Symfony\Component\HttpFoundation\File\UploadedFile; class FileStorage @@ -120,12 +120,13 @@ class FileStorage */ protected function adjustPathForStorageDisk(string $path): string { - $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); + $trimmed = str_replace('uploads/files/', '', $path); + $normalized = FilePathNormalizer::normalize($trimmed); if ($this->getStorageDiskName() === 'local_secure_attachments') { - return $path; + return $normalized; } - return 'uploads/files/' . $path; + return 'uploads/files/' . $normalized; } } diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php index 8df702e0d..da8bacb34 100644 --- a/app/Uploads/ImageStorageDisk.php +++ b/app/Uploads/ImageStorageDisk.php @@ -2,9 +2,9 @@ namespace BookStack\Uploads; +use BookStack\Util\FilePathNormalizer; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Filesystem\FilesystemAdapter; -use League\Flysystem\WhitespacePathNormalizer; use Symfony\Component\HttpFoundation\StreamedResponse; class ImageStorageDisk @@ -30,13 +30,14 @@ class ImageStorageDisk */ protected function adjustPathForDisk(string $path): string { - $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path)); + $trimmed = str_replace('uploads/images/', '', $path); + $normalized = FilePathNormalizer::normalize($trimmed); if ($this->usingSecureImages()) { - return $path; + return $normalized; } - return 'uploads/images/' . $path; + return 'uploads/images/' . $normalized; } /** diff --git a/app/Util/FilePathNormalizer.php b/app/Util/FilePathNormalizer.php new file mode 100644 index 000000000..d55fb74f8 --- /dev/null +++ b/app/Util/FilePathNormalizer.php @@ -0,0 +1,17 @@ +normalizePath($path); + } +} diff --git a/routes/web.php b/routes/web.php index 318147ef5..5bb9622e7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,12 +13,14 @@ use BookStack\Permissions\PermissionsController; use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; +use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Facades\Route; use Illuminate\View\Middleware\ShareErrorsFromSession; +// Status & Meta routes Route::get('/status', [SettingControllers\StatusController::class, 'show']); Route::get('/robots.txt', [MetaController::class, 'robots']); Route::get('/favicon.ico', [MetaController::class, 'favicon']); @@ -360,8 +362,12 @@ Route::post('/password/email', [AccessControllers\ForgotPasswordController::clas Route::get('/password/reset/{token}', [AccessControllers\ResetPasswordController::class, 'showResetForm']); Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public'); -// Metadata routes +// Help & Info routes Route::view('/help/tinymce', 'help.tinymce'); Route::view('/help/wysiwyg', 'help.wysiwyg'); +// Theme Routes +Route::get('/theme/{theme}/{path}', [ThemeController::class, 'publicFile']) + ->where('path', '.*$'); + Route::fallback([MetaController::class, 'notFound'])->name('fallback'); From 481580be172a4813ee98ad1b945d12d731e71cdb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 13 Jan 2025 16:51:07 +0000 Subject: [PATCH 08/56] Themes: Added testing and better mime sniffing for public serving Existing mime sniffer wasn't great at distinguishing between plaintext file types, so added a custom extension based mapping for common web formats that may be expected to be used with this. --- app/Http/DownloadResponseFactory.php | 2 +- app/Http/RangeSupportedStream.php | 4 ++-- app/Util/WebSafeMimeSniffer.php | 16 ++++++++++++++-- tests/ThemeTest.php | 28 ++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/app/Http/DownloadResponseFactory.php b/app/Http/DownloadResponseFactory.php index 01b3502d4..8384484ad 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -70,7 +70,7 @@ class DownloadResponseFactory public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse { $rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request); - $mime = $rangeStream->sniffMime(); + $mime = $rangeStream->sniffMime(pathinfo($fileName, PATHINFO_EXTENSION)); $headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders()); return response()->stream( diff --git a/app/Http/RangeSupportedStream.php b/app/Http/RangeSupportedStream.php index fce1e9acc..c4b007789 100644 --- a/app/Http/RangeSupportedStream.php +++ b/app/Http/RangeSupportedStream.php @@ -32,12 +32,12 @@ class RangeSupportedStream /** * Sniff a mime type from the stream. */ - public function sniffMime(): string + public function sniffMime(string $extension = ''): string { $offset = min(2000, $this->fileSize); $this->sniffContent = fread($this->stream, $offset); - return (new WebSafeMimeSniffer())->sniff($this->sniffContent); + return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension); } /** diff --git a/app/Util/WebSafeMimeSniffer.php b/app/Util/WebSafeMimeSniffer.php index b182d8ac1..4a82de85d 100644 --- a/app/Util/WebSafeMimeSniffer.php +++ b/app/Util/WebSafeMimeSniffer.php @@ -13,7 +13,7 @@ class WebSafeMimeSniffer /** * @var string[] */ - protected $safeMimes = [ + protected array $safeMimes = [ 'application/json', 'application/octet-stream', 'application/pdf', @@ -48,16 +48,28 @@ class WebSafeMimeSniffer 'video/av1', ]; + protected array $textTypesByExtension = [ + 'css' => 'text/css', + 'js' => 'text/javascript', + 'json' => 'application/json', + 'csv' => 'text/csv', + ]; + /** * Sniff the mime-type from the given file content while running the result * through an allow-list to ensure a web-safe result. * Takes the content as a reference since the value may be quite large. + * Accepts an optional $extension which can be used for further guessing. */ - public function sniff(string &$content): string + public function sniff(string &$content, string $extension = ''): string { $fInfo = new finfo(FILEINFO_MIME_TYPE); $mime = $fInfo->buffer($content) ?: 'application/octet-stream'; + if ($mime === 'text/plain' && $extension) { + $mime = $this->textTypesByExtension[$extension] ?? 'text/plain'; + } + if (in_array($mime, $this->safeMimes)) { return $mime; } diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index 837b94eee..b3c85d8f7 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -464,6 +464,34 @@ END; }); } + public function test_public_folder_contents_accessible_via_route() + { + $this->usingThemeFolder(function (string $themeFolderName) { + $publicDir = theme_path('public'); + mkdir($publicDir, 0777, true); + + $text = 'some-text ' . md5(random_bytes(5)); + $css = "body { background-color: tomato !important; }"; + file_put_contents("{$publicDir}/file.txt", $text); + file_put_contents("{$publicDir}/file.css", $css); + copy($this->files->testFilePath('test-image.png'), "{$publicDir}/image.png"); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.txt"); + $resp->assertStreamedContent($text); + $resp->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/image.png"); + $resp->assertHeader('Content-Type', 'image/png'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.css"); + $resp->assertStreamedContent($css); + $resp->assertHeader('Content-Type', 'text/css; charset=UTF-8'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + }); + } + protected function usingThemeFolder(callable $callback) { // Create a folder and configure a theme From 25c4f4b02ba06f66f5239de48ae005f895146f8d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 14 Jan 2025 14:53:10 +0000 Subject: [PATCH 09/56] Themes: Documented public file serving --- dev/docs/logical-theme-system.md | 4 +++- dev/docs/visual-theme-system.md | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/dev/docs/logical-theme-system.md b/dev/docs/logical-theme-system.md index 139055b3d..84bd26a53 100644 --- a/dev/docs/logical-theme-system.md +++ b/dev/docs/logical-theme-system.md @@ -2,7 +2,9 @@ BookStack allows logical customization via the theme system which enables you to add, or extend, functionality within the PHP side of the system without needing to alter the core application files. -WARNING: This system is currently in alpha so may incur changes. Once we've gathered some feedback on usage we'll look to removing this warning. This system will be considered semi-stable in the future. The `Theme::` system will be kept maintained but specific customizations or deeper app/framework usage using this system will not be supported nor considered in any way stable. Customizations using this system should be checked after updates. +This is part of the theme system alongside the [visual theme system](./visual-theme-system.md). + +**Note:** This system is considered semi-stable. The `Theme::` system is kept maintained but specific customizations or deeper app/framework usage using this system will not be supported nor considered in any way stable. Customizations using this system should be checked after updates. ## Getting Started diff --git a/dev/docs/visual-theme-system.md b/dev/docs/visual-theme-system.md index 6e7105a9e..8a76ddb00 100644 --- a/dev/docs/visual-theme-system.md +++ b/dev/docs/visual-theme-system.md @@ -2,7 +2,9 @@ BookStack allows visual customization via the theme system which enables you to extensively customize views, translation text & icons. -This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates. +This is part of the theme system alongside the [logical theme system](./logical-theme-system.md). + +**Note:** This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates. ## Getting Started @@ -32,3 +34,24 @@ return [ 'search' => 'find', ]; ``` + +## Publicly Accessible Files + +As part of deeper customizations you may want to expose additional files +(images, scripts, styles, etc...) as part of your theme, in a way so they're +accessible in public web-space to browsers. + +To achieve this, you can put files within a `themes//public` folder. +BookStack will serve any files within this folder from a `/theme/` base path. + +As an example, if I had an image located at `themes/custom/public/cat.jpg`, I could access +that image via the URL path `/theme/custom/cat.jpg`. That's assuming that `custom` is the currently +configured application theme. + +There are some considerations to these publicly served files: + +- Only a predetermined range "web safe" content-types are currently served. + - This limits running into potential insecure scenarios in serving problematic file types. +- A static 1-day cache time it set on files served from this folder. + - You can use alternative cache-breaking techniques (change of query string) upon changes if needed. + - If required, you could likely override caching at the webserver level. From 0d1a237f8150ed103349ce0485472fb247c3251e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 15 Jan 2025 14:15:58 +0000 Subject: [PATCH 10/56] Lexical: Fixed auto-link issue Added extra test helper to check the editor state directly via string notation access rather than juggling types/objects to access deep properties. --- .../lexical/core/__tests__/utils/index.ts | 29 +++- .../services/__tests__/auto-links.test.ts | 129 ++++++++---------- resources/js/wysiwyg/services/auto-links.ts | 2 +- resources/js/wysiwyg/todo.md | 6 +- 4 files changed, 86 insertions(+), 80 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index b13bba697..d54a64ce8 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -37,8 +37,6 @@ import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {EditorUiContext} from "../../../../ui/framework/core"; import {EditorUIManager} from "../../../../ui/framework/manager"; -import {turtle} from "@codemirror/legacy-modes/mode/turtle"; - type TestEnv = { readonly container: HTMLDivElement; @@ -47,6 +45,9 @@ type TestEnv = { readonly innerHTML: string; }; +/** + * @deprecated - Consider using `createTestContext` instead within the test case. + */ export function initializeUnitTest( runTests: (testEnv: TestEnv) => void, editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}}, @@ -795,6 +796,30 @@ export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShap expect(shape.children).toMatchObject(expected); } +/** + * Expect a given prop within the JSON editor state structure to be the given value. + * Uses dot notation for the provided `propPath`. Example: + * 0.5.cat => First child, Sixth child, cat property + */ +export function expectEditorStateJSONPropToEqual(editor: LexicalEditor, propPath: string, expected: any) { + let currentItem: any = editor.getEditorState().toJSON().root; + let currentPath = []; + const pathParts = propPath.split('.'); + + for (const part of pathParts) { + currentPath.push(part); + const childAccess = Number.isInteger(Number(part)) && Array.isArray(currentItem.children); + const target = childAccess ? currentItem.children : currentItem; + + if (typeof target[part] === 'undefined') { + throw new Error(`Could not resolve editor state at path ${currentPath.join('.')}`) + } + currentItem = target[part]; + } + + expect(currentItem).toBe(expected); +} + function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); } diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts index 30dc92565..add61c495 100644 --- a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -1,91 +1,76 @@ -import {initializeUnitTest} from "lexical/__tests__/utils"; -import {SerializedLinkNode} from "@lexical/link"; +import { + createTestContext, + dispatchKeydownEventForNode, expectEditorStateJSONPropToEqual, + expectNodeShapeToMatch +} from "lexical/__tests__/utils"; import { $getRoot, ParagraphNode, - SerializedParagraphNode, - SerializedTextNode, TextNode } from "lexical"; import {registerAutoLinks} from "../auto-links"; describe('Auto-link service tests', () => { - initializeUnitTest((testEnv) => { + test('space after link in text', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; - test('space after link in text', async () => { - const {editor} = testEnv; + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); - registerAutoLinks(editor); - let pNode!: ParagraphNode; - - editor.update(() => { - pNode = new ParagraphNode(); - const text = new TextNode('Some https://example.com?test=true text'); - pNode.append(text); - $getRoot().append(pNode); - - text.select(34, 34); - }); - - editor.commitUpdates(); - - const pDomEl = editor.getElementByKey(pNode.getKey()); - const event = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: ' ', - keyCode: 62, - }); - pDomEl?.dispatchEvent(event); - - editor.commitUpdates(); - - const paragraph = editor!.getEditorState().toJSON().root - .children[0] as SerializedParagraphNode; - expect(paragraph.children[1].type).toBe('link'); - - const link = paragraph.children[1] as SerializedLinkNode; - expect(link.url).toBe('https://example.com?test=true'); - const linkText = link.children[0] as SerializedTextNode; - expect(linkText.text).toBe('https://example.com?test=true'); + text.select(34, 34); }); - test('enter after link in text', async () => { - const {editor} = testEnv; + dispatchKeydownEventForNode(pNode, editor, ' '); - registerAutoLinks(editor); - let pNode!: ParagraphNode; + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true'); + }); - editor.update(() => { - pNode = new ParagraphNode(); - const text = new TextNode('Some https://example.com?test=true text'); - pNode.append(text); - $getRoot().append(pNode); + test('space after link at end of line', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; - text.select(34, 34); - }); + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true'); + pNode.append(text); + $getRoot().append(pNode); - editor.commitUpdates(); - - const pDomEl = editor.getElementByKey(pNode.getKey()); - const event = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: 'Enter', - keyCode: 66, - }); - pDomEl?.dispatchEvent(event); - - editor.commitUpdates(); - - const paragraph = editor!.getEditorState().toJSON().root - .children[0] as SerializedParagraphNode; - expect(paragraph.children[1].type).toBe('link'); - - const link = paragraph.children[1] as SerializedLinkNode; - expect(link.url).toBe('https://example.com?test=true'); - const linkText = link.children[0] as SerializedTextNode; - expect(linkText.text).toBe('https://example.com?test=true'); + text.selectEnd(); }); + + dispatchKeydownEventForNode(pNode, editor, ' '); + + expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [ + {text: 'Some '}, + {type: 'link', children: [{text: 'https://example.com?test=true'}]} + ]}]); + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + }); + + test('enter after link in text', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; + + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); + + text.select(34, 34); + }); + + dispatchKeydownEventForNode(pNode, editor, 'Enter'); + + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true'); }); }); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts index 1c3b1c730..62cd45994 100644 --- a/resources/js/wysiwyg/services/auto-links.ts +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -43,7 +43,7 @@ function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, edit linkNode.append(new TextNode(textSegment)); const splits = node.splitText(startIndex, cursorPoint); - const targetIndex = splits.length === 3 ? 1 : 0; + const targetIndex = startIndex > 0 ? 1 : 0; const targetText = splits[targetIndex]; if (targetText) { targetText.replace(linkNode); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 817a235a7..a49cccd26 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,11 +2,7 @@ ## In progress -Reorg - - Merge custom nodes into original nodes - - Reduce down to use CommonBlockNode where possible - - Remove existing formatType/ElementFormatType references (replaced with alignment). - - Remove existing indent references (replaced with inset). +// ## Main Todo From 7f5fd16dc601039a0ff14749c98d8ea35902ec4c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 15 Jan 2025 14:31:09 +0000 Subject: [PATCH 11/56] Lexical: Added some general test guidance Just to help remember the general layout/methods that we've added to make testing easier. --- resources/js/wysiwyg/testing.md | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 resources/js/wysiwyg/testing.md diff --git a/resources/js/wysiwyg/testing.md b/resources/js/wysiwyg/testing.md new file mode 100644 index 000000000..7b272c606 --- /dev/null +++ b/resources/js/wysiwyg/testing.md @@ -0,0 +1,55 @@ +# Testing Guidance + +This is testing guidance specific for this Lexical-based WYSIWYG editor. +There is a lot of pre-existing test code carried over form the fork of lexical, but since there we've added a range of helpers and altered how testing can be done to make things a bit simpler and aligned with how we run tests. + +This document is an attempt to document the new best options for added tests with an aim for standardisation on these approaches going forward. + +## Utils Location + +Most core test utils can be found in the file at path: resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts + +## Test Example + +This is an example of a typical test using the common modern utilities to help perform actions or assertions. Comments are for this example only, and are not expected in actual test files. + +```ts +import { + createTestContext, + dispatchKeydownEventForNode, + expectEditorStateJSONPropToEqual, + expectNodeShapeToMatch +} from "lexical/__tests__/utils"; +import { + $getRoot, + ParagraphNode, + TextNode +} from "lexical"; + +describe('A specific service or file or function', () => { + test('it does thing', async () => { + // Create the editor context and get an editor reference + const {editor} = createTestContext(); + + // Run an action within the editor. + let pNode: ParagraphNode; + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Hello!'); + pNode.append(text); + $getRoot().append(pNode); + }); + + // Dispatch key events via the DOM + dispatchKeydownEventForNode(pNode!, editor, ' '); + + // Check the shape (and text) of the resulting state + expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [ + {text: 'Hello!'}, + ]}]); + + // Check specific props in the resulting JSON state + expectEditorStateJSONPropToEqual(editor, '0.0.text', 'Hello!'); + }); +}); +``` \ No newline at end of file From c091f67db334024bd6b4c65d1833b2c60e3e0a45 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 17 Jan 2025 11:15:14 +0000 Subject: [PATCH 12/56] Lexical: Added color format custom color select Includes tracking of selected colors via localstorage for display. --- resources/icons/editor/color-select.svg | 1 + .../ui/framework/blocks/color-picker.ts | 49 ++++++++++++++++++- resources/js/wysiwyg/ui/framework/core.ts | 7 +++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 resources/icons/editor/color-select.svg diff --git a/resources/icons/editor/color-select.svg b/resources/icons/editor/color-select.svg new file mode 100644 index 000000000..cef686655 --- /dev/null +++ b/resources/icons/editor/color-select.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts index b068fb4f0..65623e1b2 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -4,6 +4,8 @@ import {$patchStyleText} from "@lexical/selection"; import {el} from "../../../utils/dom"; import removeIcon from "@icons/editor/color-clear.svg"; +import selectIcon from "@icons/editor/color-select.svg"; +import {uniqueIdSmall} from "../../../../services/util"; const colorChoices = [ '#000000', @@ -34,6 +36,8 @@ const colorChoices = [ '#34495E', ]; +const storageKey = 'bs-lexical-custom-colors'; + export class EditorColorPicker extends EditorUiElement { protected styleProperty: string; @@ -44,8 +48,10 @@ export class EditorColorPicker extends EditorUiElement { } buildDOM(): HTMLElement { + const id = uniqueIdSmall(); - const colorOptions = colorChoices.map(choice => { + const allChoices = [...colorChoices, ...this.getCustomColorChoices()]; + const colorOptions = allChoices.map(choice => { return el('div', { class: 'editor-color-select-option', style: `background-color: ${choice}`, @@ -62,6 +68,25 @@ export class EditorColorPicker extends EditorUiElement { removeButton.innerHTML = removeIcon; colorOptions.push(removeButton); + const selectButton = el('label', { + class: 'editor-color-select-option', + for: `color-select-${id}`, + 'data-color': '', + title: 'Custom color', + }, []); + selectButton.innerHTML = selectIcon; + colorOptions.push(selectButton); + + const input = el('input', {type: 'color', hidden: 'true', id: `color-select-${id}`}) as HTMLInputElement; + colorOptions.push(input); + input.addEventListener('change', e => { + if (input.value) { + this.storeCustomColorChoice(input.value); + this.setColor(input.value); + this.rebuildDOM(); + } + }); + const colorRows = []; for (let i = 0; i < colorOptions.length; i+=5) { const options = colorOptions.slice(i, i + 5); @@ -79,11 +104,33 @@ export class EditorColorPicker extends EditorUiElement { return wrapper; } + storeCustomColorChoice(color: string) { + if (colorChoices.includes(color)) { + return; + } + + const customColors: string[] = this.getCustomColorChoices(); + if (customColors.includes(color)) { + return; + } + + customColors.push(color); + window.localStorage.setItem(storageKey, JSON.stringify(customColors)); + } + + getCustomColorChoices(): string[] { + return JSON.parse(window.localStorage.getItem(storageKey) || '[]'); + } + onClick(event: MouseEvent) { const colorEl = (event.target as HTMLElement).closest('[data-color]') as HTMLElement; if (!colorEl) return; const color = colorEl.dataset.color as string; + this.setColor(color); + } + + setColor(color: string) { this.getContext().editor.update(() => { const selection = $getSelection(); if (selection) { diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 3433b96e8..90ce4ebf9 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -53,6 +53,13 @@ export abstract class EditorUiElement { return this.dom; } + rebuildDOM(): HTMLElement { + const newDOM = this.buildDOM(); + this.dom?.replaceWith(newDOM); + this.dom = newDOM; + return this.dom; + } + trans(text: string) { return this.getContext().translate(text); } From 04cca77ae6d84e0f7c3aceef6c0bc3682258c5c9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 18 Jan 2025 11:12:43 +0000 Subject: [PATCH 13/56] Lexical: Added color picker/indicator to form fields --- resources/icons/editor/color-display.svg | 10 ++++ resources/js/wysiwyg/todo.md | 4 -- .../ui/defaults/buttons/inline-formats.ts | 14 +++++ .../js/wysiwyg/ui/defaults/forms/tables.ts | 21 +++---- resources/js/wysiwyg/ui/defaults/toolbars.ts | 8 +-- .../ui/framework/blocks/color-field.ts | 56 +++++++++++++++++++ .../ui/framework/blocks/color-picker.ts | 19 +++---- .../wysiwyg/ui/framework/blocks/link-field.ts | 2 - resources/js/wysiwyg/ui/framework/forms.ts | 17 ++++-- resources/sass/_editor.scss | 10 ++++ 10 files changed, 125 insertions(+), 36 deletions(-) create mode 100644 resources/icons/editor/color-display.svg create mode 100644 resources/js/wysiwyg/ui/framework/blocks/color-field.ts diff --git a/resources/icons/editor/color-display.svg b/resources/icons/editor/color-display.svg new file mode 100644 index 000000000..86be9a7bf --- /dev/null +++ b/resources/icons/editor/color-display.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index a49cccd26..695e8cb69 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -10,13 +10,9 @@ ## Secondary Todo -- Color picker support in table form color fields -- Color picker for color controls - Table caption text support - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Deep check of translation coverage -- About button & view -- Mobile display and handling ## Bugs diff --git a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts index c3726acf0..c5b7ad29a 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts @@ -12,6 +12,8 @@ import subscriptIcon from "@icons/editor/subscript.svg"; import codeIcon from "@icons/editor/code.svg"; import formatClearIcon from "@icons/editor/format-clear.svg"; import {$selectionContainsTextFormat} from "../../../utils/selection"; +import {$patchStyleText} from "@lexical/selection"; +import {context} from "esbuild"; function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition { return { @@ -32,6 +34,18 @@ export const underline: EditorButtonDefinition = buildFormatButton('Underline', export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon}; export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon}; +function colorAction(context: EditorUiContext, property: string, color: string): void { + context.editor.update(() => { + const selection = $getSelection(); + if (selection) { + $patchStyleText(selection, {[property]: color || null}); + } + }); +} + +export const textColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color); +export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color); + export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon); diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 63fa24c80..b592d7c67 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -1,6 +1,6 @@ import { EditorFormDefinition, - EditorFormFieldDefinition, + EditorFormFieldDefinition, EditorFormFields, EditorFormTabs, EditorSelectFormFieldDefinition } from "../../framework/forms"; @@ -17,6 +17,7 @@ import { import {formatSizeValue} from "../../../utils/dom"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {CommonBlockAlignment} from "lexical/nodes/common"; +import {colorFieldBuilder} from "../../framework/blocks/color-field"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -145,15 +146,15 @@ export const cellProperties: EditorFormDefinition = { } as EditorSelectFormFieldDefinition, ]; - const advancedFields: EditorFormFieldDefinition[] = [ + const advancedFields: EditorFormFields = [ { label: 'Border width', // inline-style: border-width name: 'border_width', type: 'text', }, borderStyleInput, // inline-style: border-style - borderColorInput, // inline-style: border-color - backgroundColorInput, // inline-style: background-color + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ]; return new EditorFormTabs([ @@ -210,8 +211,8 @@ export const rowProperties: EditorFormDefinition = { type: 'text', }, borderStyleInput, // style on tr: height - borderColorInput, // style on tr: height - backgroundColorInput, // style on tr: height + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ], }; @@ -305,10 +306,10 @@ export const tableProperties: EditorFormDefinition = { alignmentInput, // alignment class ]; - const advancedFields: EditorFormFieldDefinition[] = [ - borderStyleInput, // Style - border-style - borderColorInput, // Style - border-color - backgroundColorInput, // Style - background-color + const advancedFields: EditorFormFields = [ + borderStyleInput, + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ]; return new EditorFormTabs([ diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index 61baa3c32..b09a7530f 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -44,11 +44,11 @@ import { } from "./buttons/block-formats"; import { bold, clearFormating, code, - highlightColor, + highlightColor, highlightColorAction, italic, strikethrough, subscript, superscript, - textColor, + textColor, textColorAction, underline } from "./buttons/inline-formats"; import { @@ -114,10 +114,10 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai new EditorButton(italic), new EditorButton(underline), new EditorDropdownButton({ button: new EditorColorButton(textColor, 'color') }, [ - new EditorColorPicker('color'), + new EditorColorPicker(textColorAction), ]), new EditorDropdownButton({button: new EditorColorButton(highlightColor, 'background-color')}, [ - new EditorColorPicker('background-color'), + new EditorColorPicker(highlightColorAction), ]), new EditorButton(strikethrough), new EditorButton(superscript), diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-field.ts b/resources/js/wysiwyg/ui/framework/blocks/color-field.ts new file mode 100644 index 000000000..8c8f167d9 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/color-field.ts @@ -0,0 +1,56 @@ +import {EditorContainerUiElement, EditorUiBuilderDefinition, EditorUiContext} from "../core"; +import {EditorFormField, EditorFormFieldDefinition} from "../forms"; +import {EditorColorPicker} from "./color-picker"; +import {EditorDropdownButton} from "./dropdown-button"; + +import colorDisplayIcon from "@icons/editor/color-display.svg" + +export class EditorColorField extends EditorContainerUiElement { + protected input: EditorFormField; + protected pickerButton: EditorDropdownButton; + + constructor(input: EditorFormField) { + super([]); + + this.input = input; + + this.pickerButton = new EditorDropdownButton({ + button: { icon: colorDisplayIcon, label: 'Select color'} + }, [ + new EditorColorPicker(this.onColorSelect.bind(this)) + ]); + this.addChildren(this.pickerButton, this.input); + } + + protected buildDOM(): HTMLElement { + const dom = this.input.getDOMElement(); + dom.append(this.pickerButton.getDOMElement()); + dom.classList.add('editor-color-field-container'); + + const field = dom.querySelector('input') as HTMLInputElement; + field.addEventListener('change', () => { + this.setIconColor(field.value); + }); + + return dom; + } + + onColorSelect(color: string, context: EditorUiContext): void { + this.input.setValue(color); + } + + setIconColor(color: string) { + const icon = this.getDOMElement().querySelector('svg .editor-icon-color-display'); + if (icon) { + icon.setAttribute('fill', color || 'url(#pattern2)'); + } + } +} + +export function colorFieldBuilder(field: EditorFormFieldDefinition): EditorUiBuilderDefinition { + return { + build() { + return new EditorColorField(new EditorFormField(field)); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts index 65623e1b2..c742ddc77 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -1,6 +1,4 @@ -import {EditorUiElement} from "../core"; -import {$getSelection} from "lexical"; -import {$patchStyleText} from "@lexical/selection"; +import {EditorUiContext, EditorUiElement} from "../core"; import {el} from "../../../utils/dom"; import removeIcon from "@icons/editor/color-clear.svg"; @@ -38,13 +36,15 @@ const colorChoices = [ const storageKey = 'bs-lexical-custom-colors'; +export type EditorColorPickerCallback = (color: string, context: EditorUiContext) => void; + export class EditorColorPicker extends EditorUiElement { - protected styleProperty: string; + protected callback: EditorColorPickerCallback; - constructor(styleProperty: string) { + constructor(callback: EditorColorPickerCallback) { super(); - this.styleProperty = styleProperty; + this.callback = callback; } buildDOM(): HTMLElement { @@ -131,11 +131,6 @@ export class EditorColorPicker extends EditorUiElement { } setColor(color: string) { - this.getContext().editor.update(() => { - const selection = $getSelection(); - if (selection) { - $patchStyleText(selection, {[this.styleProperty]: color || null}); - } - }); + this.callback(color, this.getContext()); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts index f88b22c3f..880238a9a 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts @@ -44,7 +44,6 @@ export class LinkField extends EditorContainerUiElement { updateFormFromHeader(header: HeadingNode) { this.getHeaderIdAndText(header).then(({id, text}) => { - console.log('updating form', id, text); const modal = this.getContext().manager.getActiveModal('link'); if (modal) { modal.getForm().setValues({ @@ -60,7 +59,6 @@ export class LinkField extends EditorContainerUiElement { return new Promise((res) => { this.getContext().editor.update(() => { let id = header.getId(); - console.log('header', id, header.__id); if (!id) { id = 'header-' + uniqueIdSmall(); header.setId(id); diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index 36371e302..771ab0bdf 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -19,15 +19,17 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti valuesByLabel: Record } +export type EditorFormFields = (EditorFormFieldDefinition|EditorUiBuilderDefinition)[]; + interface EditorFormTabDefinition { label: string; - contents: EditorFormFieldDefinition[]; + contents: EditorFormFields; } export interface EditorFormDefinition { submitText: string; action: (formData: FormData, context: EditorUiContext) => Promise; - fields: (EditorFormFieldDefinition|EditorUiBuilderDefinition)[]; + fields: EditorFormFields; } export class EditorFormField extends EditorUiElement { @@ -41,6 +43,7 @@ export class EditorFormField extends EditorUiElement { setValue(value: string) { const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement; input.value = value; + input.dispatchEvent(new Event('change')); } getName(): string { @@ -155,11 +158,17 @@ export class EditorForm extends EditorContainerUiElement { export class EditorFormTab extends EditorContainerUiElement { protected definition: EditorFormTabDefinition; - protected fields: EditorFormField[]; + protected fields: EditorUiElement[]; protected id: string; constructor(definition: EditorFormTabDefinition) { - const fields = definition.contents.map(fieldDef => new EditorFormField(fieldDef)); + const fields = definition.contents.map(fieldDef => { + if (isUiBuilderDefinition(fieldDef)) { + return fieldDef.build(); + } + return new EditorFormField(fieldDef) + }); + super(fields); this.definition = definition; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 2446c1416..9f7694e85 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -649,6 +649,16 @@ textarea.editor-form-field-input { width: $inputWidth - 40px; } } +.editor-color-field-container { + position: relative; + input { + padding-left: 36px; + } + .editor-dropdown-menu-container { + position: absolute; + bottom: 0; + } +} // Editor theme styles .editor-theme-bold { From 8a66365d48f8c1b4a8926dd632fe0fb1868cdc43 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Jan 2025 12:54:13 +0000 Subject: [PATCH 14/56] Lexical: Added support for table caption nodes Needs linking up to the table form still. --- .../js/wysiwyg/lexical/core/LexicalNode.ts | 10 +++ .../wysiwyg/lexical/core/LexicalReconciler.ts | 21 ++++-- .../lexical/table/LexicalCaptionNode.ts | 74 +++++++++++++++++++ .../wysiwyg/lexical/table/LexicalTableNode.ts | 2 + resources/js/wysiwyg/nodes.ts | 2 + 5 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index a6c9b6023..163bb8c31 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -1165,6 +1165,16 @@ export class LexicalNode { markDirty(): void { this.getWritable(); } + + /** + * Insert the DOM of this node into that of the parent. + * Allows this node to implement custom DOM attachment logic. + * Boolean result indicates if the insertion was handled by the function. + * A true return value prevents default insertion logic from taking place. + */ + insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean { + return false; + } } function errorOnTypeKlassMismatch( diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts index fccf1ae23..297e96ce0 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -171,16 +171,21 @@ function $createNode( } if (parentDOM !== null) { - if (insertDOM != null) { - parentDOM.insertBefore(dom, insertDOM); - } else { - // @ts-expect-error: internal field - const possibleLineBreak = parentDOM.__lexicalLineBreak; - if (possibleLineBreak != null) { - parentDOM.insertBefore(dom, possibleLineBreak); + const inserted = node?.insertDOMIntoParent(dom, parentDOM); + + if (!inserted) { + if (insertDOM != null) { + parentDOM.insertBefore(dom, insertDOM); } else { - parentDOM.appendChild(dom); + // @ts-expect-error: internal field + const possibleLineBreak = parentDOM.__lexicalLineBreak; + + if (possibleLineBreak != null) { + parentDOM.insertBefore(dom, possibleLineBreak); + } else { + parentDOM.appendChild(dom); + } } } } diff --git a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts new file mode 100644 index 000000000..08c6870e6 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts @@ -0,0 +1,74 @@ +import { + DOMConversionMap, + DOMExportOutput, + EditorConfig, + ElementNode, + LexicalEditor, + LexicalNode, + SerializedElementNode +} from "lexical"; + + +export class CaptionNode extends ElementNode { + static getType(): string { + return 'caption'; + } + + static clone(node: CaptionNode): CaptionNode { + return new CaptionNode(node.__key); + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement { + return document.createElement('caption'); + } + + updateDOM(_prevNode: unknown, _dom: HTMLElement, _config: EditorConfig): boolean { + return false; + } + + isParentRequired(): true { + return true; + } + + canBeEmpty(): boolean { + return false; + } + + exportJSON(): SerializedElementNode { + return { + ...super.exportJSON(), + type: 'caption', + version: 1, + }; + } + + insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean { + parentDOM.insertBefore(nodeDOM, parentDOM.firstChild); + return true; + } + + static importJSON(serializedNode: SerializedElementNode): CaptionNode { + return $createCaptionNode(); + } + + static importDOM(): DOMConversionMap | null { + return { + caption: (node: Node) => ({ + conversion(domNode: Node) { + return { + node: $createCaptionNode(), + } + }, + priority: 0, + }), + }; + } +} + +export function $createCaptionNode(): CaptionNode { + return new CaptionNode(); +} + +export function $isCaptionNode(node: LexicalNode | null | undefined): node is CaptionNode { + return node instanceof CaptionNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts index 9443747a6..a10361475 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -139,6 +139,8 @@ export class TableNode extends CommonBlockNode { for (const child of Array.from(tableElement.children)) { if (child.nodeName === 'TR') { tBody.append(child); + } else if (child.nodeName === 'CAPTION') { + newElement.insertBefore(child, newElement.firstChild); } else { newElement.append(child); } diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index 8a47f322d..c1db0f086 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -18,6 +18,7 @@ import {EditorUiContext} from "./ui/framework/core"; import {MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; +import {CaptionNode} from "@lexical/table/LexicalCaptionNode"; /** * Load the nodes for lexical. @@ -32,6 +33,7 @@ export function getNodesForPageEditor(): (KlassConstructor | TableNode, TableRowNode, TableCellNode, + CaptionNode, ImageNode, // TODO - Alignment HorizontalRuleNode, DetailsNode, From 958b537a49c442699ec1834d437ce55c8db6394a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Jan 2025 20:39:15 +0000 Subject: [PATCH 15/56] Lexical: Linked table form to have caption toggle option --- .../lexical/table/LexicalCaptionNode.ts | 18 +++++++++++++++++ resources/js/wysiwyg/todo.md | 1 - .../js/wysiwyg/ui/defaults/forms/tables.ts | 20 +++++++++++++++---- resources/js/wysiwyg/ui/framework/forms.ts | 10 ++++++++-- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts index 08c6870e6..d9d83562c 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts @@ -1,4 +1,5 @@ import { + $createTextNode, DOMConversionMap, DOMExportOutput, EditorConfig, @@ -7,6 +8,7 @@ import { LexicalNode, SerializedElementNode } from "lexical"; +import {TableNode} from "@lexical/table/LexicalTableNode"; export class CaptionNode extends ElementNode { @@ -71,4 +73,20 @@ export function $createCaptionNode(): CaptionNode { export function $isCaptionNode(node: LexicalNode | null | undefined): node is CaptionNode { return node instanceof CaptionNode; +} + +export function $tableHasCaption(table: TableNode): boolean { + for (const child of table.getChildren()) { + if ($isCaptionNode(child)) { + return true; + } + } + return false; +} + +export function $addCaptionToTable(table: TableNode, text: string = ''): void { + const caption = $createCaptionNode(); + const textNode = $createTextNode(text || ' '); + caption.append(textNode); + table.append(caption); } \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 695e8cb69..1d42ba3e4 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -10,7 +10,6 @@ ## Secondary Todo -- Table caption text support - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Deep check of translation coverage diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index b592d7c67..5b484310d 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -18,6 +18,7 @@ import {formatSizeValue} from "../../../utils/dom"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {CommonBlockAlignment} from "lexical/nodes/common"; import {colorFieldBuilder} from "../../framework/blocks/color-field"; +import {$addCaptionToTable, $isCaptionNode, $tableHasCaption} from "@lexical/table/LexicalCaptionNode"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -219,6 +220,7 @@ export const rowProperties: EditorFormDefinition = { export function $showTablePropertiesForm(table: TableNode, context: EditorUiContext): EditorFormModal { const styles = table.getStyles(); const modalForm = context.manager.createModal('table_properties'); + modalForm.show({ width: styles.get('width') || '', height: styles.get('height') || '', @@ -228,7 +230,7 @@ export function $showTablePropertiesForm(table: TableNode, context: EditorUiCont border_style: styles.get('border-style') || '', border_color: styles.get('border-color') || '', background_color: styles.get('background-color') || '', - // caption: '', TODO + caption: $tableHasCaption(table) ? 'true' : '', align: table.getAlignment(), }); return modalForm; @@ -265,7 +267,17 @@ export const tableProperties: EditorFormDefinition = { }); } - // TODO - cell caption + const showCaption = Boolean(formData.get('caption')?.toString() || ''); + const hasCaption = $tableHasCaption(table); + if (showCaption && !hasCaption) { + $addCaptionToTable(table, context.translate('Caption')); + } else if (!showCaption && hasCaption) { + for (const child of table.getChildren()) { + if ($isCaptionNode(child)) { + child.remove(); + } + } + } }); return true; }, @@ -299,9 +311,9 @@ export const tableProperties: EditorFormDefinition = { type: 'text', }, { - label: 'caption', // Caption element + label: 'Show caption', // Caption element name: 'caption', - type: 'text', // TODO - + type: 'checkbox', }, alignmentInput, // alignment class ]; diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index 771ab0bdf..08edb214e 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -11,7 +11,7 @@ import {el} from "../../utils/dom"; export interface EditorFormFieldDefinition { label: string; name: string; - type: 'text' | 'select' | 'textarea'; + type: 'text' | 'select' | 'textarea' | 'checkbox'; } export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition { @@ -42,7 +42,11 @@ export class EditorFormField extends EditorUiElement { setValue(value: string) { const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement; - input.value = value; + if (this.definition.type === 'checkbox') { + input.checked = Boolean(value); + } else { + input.value = value; + } input.dispatchEvent(new Event('change')); } @@ -61,6 +65,8 @@ export class EditorFormField extends EditorUiElement { input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems); } else if (this.definition.type === 'textarea') { input = el('textarea', {id, name: this.definition.name, class: 'editor-form-field-input'}); + } else if (this.definition.type === 'checkbox') { + input = el('input', {id, name: this.definition.name, type: 'checkbox', class: 'editor-form-field-input-checkbox', value: 'true'}); } else { input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'}); } From d89a2fdb150880bf98292bff3e16083179709ffb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Jan 2025 14:28:27 +0000 Subject: [PATCH 16/56] Lexical: Added media src conversions Only actuall added YT in the end. Google had changed URL scheme, and Vimeo seems to just be something else now, can't really browse video pages like before. --- .../lexical/rich-text/LexicalMediaNode.ts | 49 ++++++++++++++++++- resources/js/wysiwyg/todo.md | 1 - .../js/wysiwyg/ui/defaults/buttons/objects.ts | 17 +------ .../js/wysiwyg/ui/defaults/forms/objects.ts | 34 +++++++++++-- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts index a675665ac..81fb96a93 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts @@ -16,6 +16,7 @@ import { } from "lexical/nodes/common"; import {$selectSingleNode} from "../../utils/selection"; import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; +import * as url from "node:url"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { @@ -343,11 +344,55 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null { return domElementToNode(tag as MediaNodeTag, el); } +interface UrlPattern { + readonly regex: RegExp; + readonly w: number; + readonly h: number; + readonly url: string; +} + +/** + * These patterns originate from the tinymce/tinymce project. + * https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts + * License: MIT Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc. + * License Link: https://github.com/tinymce/tinymce/blob/584a150679669859a528828e5d2910a083b1d911/LICENSE.TXT + */ +const urlPatterns: UrlPattern[] = [ + { + regex: /.*?youtu\.be\/([\w\-_\?&=.]+)/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$1', + }, + { + regex: /.*youtube\.com(.+)v=([^&]+)(&([a-z0-9&=\-_]+))?.*/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$2?$4', + }, + { + regex: /.*youtube.com\/embed\/([a-z0-9\?&=\-_]+).*/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$1', + }, +]; + const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov']; const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm']; const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx', '']; export function $createMediaNodeFromSrc(src: string): MediaNode { + + for (const pattern of urlPatterns) { + const match = src.match(pattern.regex); + if (match) { + const newSrc = src.replace(pattern.regex, pattern.url); + const node = new MediaNode('iframe'); + node.setSrc(newSrc); + node.setHeight(pattern.h); + node.setWidth(pattern.w); + return node; + } + } + let nodeTag: MediaNodeTag = 'iframe'; const srcEnd = src.split('?')[0].split('/').pop() || ''; const srcEndSplit = srcEnd.split('.'); @@ -360,7 +405,9 @@ export function $createMediaNodeFromSrc(src: string): MediaNode { nodeTag = 'embed'; } - return new MediaNode(nodeTag); + const node = new MediaNode(nodeTag); + node.setSrc(src); + return node; } export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode { diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 1d42ba3e4..94ae0e144 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -10,7 +10,6 @@ ## Secondary Todo -- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Deep check of translation coverage ## Bugs diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 6612c0dc4..63df4fea8 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -32,7 +32,7 @@ import { } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; -import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects"; +import {$showDetailsForm, $showImageForm, $showLinkForm, $showMediaForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { @@ -168,24 +168,11 @@ export const media: EditorButtonDefinition = { label: 'Insert/edit Media', icon: mediaIcon, action(context: EditorUiContext) { - const mediaModal = context.manager.createModal('media'); - context.editor.getEditorState().read(() => { const selection = $getSelection(); const selectedNode = $getNodeFromSelection(selection, $isMediaNode) as MediaNode | null; - let formDefaults = {}; - if (selectedNode) { - const nodeAttrs = selectedNode.getAttributes(); - formDefaults = { - src: nodeAttrs.src || nodeAttrs.data || '', - width: nodeAttrs.width, - height: nodeAttrs.height, - embed: '', - } - } - - mediaModal.show(formDefaults); + $showMediaForm(selectedNode, context); }); }, isActive(selection: BaseSelection | null): boolean { diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 21d333c3a..0effdc171 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -186,6 +186,23 @@ export const link: EditorFormDefinition = { ], }; +export function $showMediaForm(media: MediaNode|null, context: EditorUiContext): void { + const mediaModal = context.manager.createModal('media'); + + let formDefaults = {}; + if (media) { + const nodeAttrs = media.getAttributes(); + formDefaults = { + src: nodeAttrs.src || nodeAttrs.data || '', + width: nodeAttrs.width, + height: nodeAttrs.height, + embed: '', + } + } + + mediaModal.show(formDefaults); +} + export const media: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { @@ -215,12 +232,19 @@ export const media: EditorFormDefinition = { const height = (formData.get('height') || '').toString().trim(); const width = (formData.get('width') || '').toString().trim(); - const updateNode = selectedNode || $createMediaNodeFromSrc(src); - updateNode.setSrc(src); - updateNode.setWidthAndHeight(width, height); - if (!selectedNode) { - $insertNodes([updateNode]); + // Update existing + if (selectedNode) { + selectedNode.setSrc(src); + selectedNode.setWidthAndHeight(width, height); + return; } + + // Insert new + const node = $createMediaNodeFromSrc(src); + if (width || height) { + node.setWidthAndHeight(width, height); + } + $insertNodes([node]); }); return true; From 7e03a973d88999f1e22b601d0b2f6c947d0bd5fc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Jan 2025 16:40:41 +0000 Subject: [PATCH 17/56] Lexical: Ran a deeper check on translation use --- lang/en/editor.php | 2 ++ resources/js/wysiwyg/todo.md | 17 ----------------- .../js/wysiwyg/ui/defaults/buttons/controls.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 2 +- .../wysiwyg/ui/framework/blocks/color-picker.ts | 4 ++-- 5 files changed, 6 insertions(+), 21 deletions(-) delete mode 100644 resources/js/wysiwyg/todo.md diff --git a/lang/en/editor.php b/lang/en/editor.php index a61b46042..752c6f3f7 100644 --- a/lang/en/editor.php +++ b/lang/en/editor.php @@ -13,6 +13,7 @@ return [ 'cancel' => 'Cancel', 'save' => 'Save', 'close' => 'Close', + 'apply' => 'Apply', 'undo' => 'Undo', 'redo' => 'Redo', 'left' => 'Left', @@ -147,6 +148,7 @@ return [ 'url' => 'URL', 'text_to_display' => 'Text to display', 'title' => 'Title', + 'browse_links' => 'Browse links', 'open_link' => 'Open link', 'open_link_in' => 'Open link in...', 'open_link_current' => 'Current window', diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md deleted file mode 100644 index 94ae0e144..000000000 --- a/resources/js/wysiwyg/todo.md +++ /dev/null @@ -1,17 +0,0 @@ -# Lexical based editor todo - -## In progress - -// - -## Main Todo - -// - -## Secondary Todo - -- Deep check of translation coverage - -## Bugs - -// \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts index 5e3200539..6c22d3faa 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -57,7 +57,7 @@ export const redo: EditorButtonDefinition = { export const source: EditorButtonDefinition = { - label: 'Source', + label: 'Source code', icon: sourceIcon, async action(context: EditorUiContext) { const modal = context.manager.createModal('source'); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 63df4fea8..4eb4c5a4e 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -165,7 +165,7 @@ export const diagramManager: EditorButtonDefinition = { }; export const media: EditorButtonDefinition = { - label: 'Insert/edit Media', + label: 'Insert/edit media', icon: mediaIcon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts index c742ddc77..8e62a0e5e 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -63,7 +63,7 @@ export class EditorColorPicker extends EditorUiElement { const removeButton = el('div', { class: 'editor-color-select-option', 'data-color': '', - title: 'Clear color', + title: this.getContext().translate('Remove color'), }, []); removeButton.innerHTML = removeIcon; colorOptions.push(removeButton); @@ -72,7 +72,7 @@ export class EditorColorPicker extends EditorUiElement { class: 'editor-color-select-option', for: `color-select-${id}`, 'data-color': '', - title: 'Custom color', + title: this.getContext().translate('Custom color'), }, []); selectButton.innerHTML = selectIcon; colorOptions.push(selectButton); From ac0cd9995d8b420e33e392ba82d40bde8df94205 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Jan 2025 16:40:11 +0000 Subject: [PATCH 18/56] Sorting: Reorganised book sort code to its own directory --- app/Entities/Tools/BookContents.php | 209 +--------------- .../BookSortController.php | 8 +- .../Tools => Sorting}/BookSortMap.php | 2 +- .../Tools => Sorting}/BookSortMapItem.php | 2 +- app/Sorting/BookSorter.php | 226 ++++++++++++++++++ routes/web.php | 7 +- 6 files changed, 237 insertions(+), 217 deletions(-) rename app/{Entities/Controllers => Sorting}/BookSortController.php (88%) rename app/{Entities/Tools => Sorting}/BookSortMap.php (96%) rename app/{Entities/Tools => Sorting}/BookSortMapItem.php (94%) create mode 100644 app/Sorting/BookSorter.php diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 7fa2134b7..7dd3f3e11 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -8,6 +8,8 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; +use BookStack\Sorting\BookSortMap; +use BookStack\Sorting\BookSortMapItem; use Illuminate\Support\Collection; class BookContents @@ -103,211 +105,4 @@ class BookContents return $query->where('book_id', '=', $this->book->id)->get(); } - - /** - * Sort the books content using the given sort map. - * Returns a list of books that were involved in the operation. - * - * @returns Book[] - */ - public function sortUsingMap(BookSortMap $sortMap): array - { - // Load models into map - $modelMap = $this->loadModelsFromSortMap($sortMap); - - // Sort our changes from our map to be chapters first - // Since they need to be process to ensure book alignment for child page changes. - $sortMapItems = $sortMap->all(); - usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) { - $aScore = $itemA->type === 'page' ? 2 : 1; - $bScore = $itemB->type === 'page' ? 2 : 1; - - return $aScore - $bScore; - }); - - // Perform the sort - foreach ($sortMapItems as $item) { - $this->applySortUpdates($item, $modelMap); - } - - /** @var Book[] $booksInvolved */ - $booksInvolved = array_values(array_filter($modelMap, function (string $key) { - return str_starts_with($key, 'book:'); - }, ARRAY_FILTER_USE_KEY)); - - // Update permissions of books involved - foreach ($booksInvolved as $book) { - $book->rebuildPermissions(); - } - - return $booksInvolved; - } - - /** - * Using the given sort map item, detect changes for the related model - * and update it if required. Changes where permissions are lacking will - * be skipped and not throw an error. - * - * @param array $modelMap - */ - protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void - { - /** @var BookChild $model */ - $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null; - if (!$model) { - return; - } - - $priorityChanged = $model->priority !== $sortMapItem->sort; - $bookChanged = $model->book_id !== $sortMapItem->parentBookId; - $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId; - - // Stop if there's no change - if (!$priorityChanged && !$bookChanged && !$chapterChanged) { - return; - } - - $currentParentKey = 'book:' . $model->book_id; - if ($model instanceof Page && $model->chapter_id) { - $currentParentKey = 'chapter:' . $model->chapter_id; - } - - $currentParent = $modelMap[$currentParentKey] ?? null; - /** @var Book $newBook */ - $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null; - /** @var ?Chapter $newChapter */ - $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null; - - if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) { - return; - } - - // Action the required changes - if ($bookChanged) { - $model->changeBook($newBook->id); - } - - if ($model instanceof Page && $chapterChanged) { - $model->chapter_id = $newChapter->id ?? 0; - } - - if ($priorityChanged) { - $model->priority = $sortMapItem->sort; - } - - if ($chapterChanged || $priorityChanged) { - $model->save(); - } - } - - /** - * Check if the current user has permissions to apply the given sorting change. - * Is quite complex since items can gain a different parent change. Acts as a: - * - Update of old parent element (Change of content/order). - * - Update of sorted/moved element. - * - Deletion of element (Relative to parent upon move). - * - Creation of element within parent (Upon move to new parent). - */ - protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool - { - // Stop if we can't see the current parent or new book. - if (!$currentParent || !$newBook) { - return false; - } - - $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0)); - if ($model instanceof Chapter) { - $hasPermission = userCan('book-update', $currentParent) - && userCan('book-update', $newBook) - && userCan('chapter-update', $model) - && (!$hasNewParent || userCan('chapter-create', $newBook)) - && (!$hasNewParent || userCan('chapter-delete', $model)); - - if (!$hasPermission) { - return false; - } - } - - if ($model instanceof Page) { - $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update'; - $hasCurrentParentPermission = userCan($parentPermission, $currentParent); - - // This needs to check if there was an intended chapter location in the original sort map - // rather than inferring from the $newChapter since that variable may be null - // due to other reasons (Visibility). - $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook; - if (!$newParent) { - return false; - } - - $hasPageEditPermission = userCan('page-update', $model); - $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id)); - $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update'; - $hasNewParentPermission = userCan($newParentPermission, $newParent); - - $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model)); - $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent)); - - $hasPermission = $hasCurrentParentPermission - && $newParentInRightLocation - && $hasNewParentPermission - && $hasPageEditPermission - && $hasDeletePermissionIfMoving - && $hasCreatePermissionIfMoving; - - if (!$hasPermission) { - return false; - } - } - - return true; - } - - /** - * Load models from the database into the given sort map. - * - * @return array - */ - protected function loadModelsFromSortMap(BookSortMap $sortMap): array - { - $modelMap = []; - $ids = [ - 'chapter' => [], - 'page' => [], - 'book' => [], - ]; - - foreach ($sortMap->all() as $sortMapItem) { - $ids[$sortMapItem->type][] = $sortMapItem->id; - $ids['book'][] = $sortMapItem->parentBookId; - if ($sortMapItem->parentChapterId) { - $ids['chapter'][] = $sortMapItem->parentChapterId; - } - } - - $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get(); - /** @var Page $page */ - foreach ($pages as $page) { - $modelMap['page:' . $page->id] = $page; - $ids['book'][] = $page->book_id; - if ($page->chapter_id) { - $ids['chapter'][] = $page->chapter_id; - } - } - - $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get(); - /** @var Chapter $chapter */ - foreach ($chapters as $chapter) { - $modelMap['chapter:' . $chapter->id] = $chapter; - $ids['book'][] = $chapter->book_id; - } - - $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get(); - /** @var Book $book */ - foreach ($books as $book) { - $modelMap['book:' . $book->id] = $book; - } - - return $modelMap; - } } diff --git a/app/Entities/Controllers/BookSortController.php b/app/Sorting/BookSortController.php similarity index 88% rename from app/Entities/Controllers/BookSortController.php rename to app/Sorting/BookSortController.php index 5aefc5832..feed5db4f 100644 --- a/app/Entities/Controllers/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -1,11 +1,10 @@ queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission('book-update', $book); @@ -58,8 +57,7 @@ class BookSortController extends Controller } $sortMap = BookSortMap::fromJson($request->get('sort-tree')); - $bookContents = new BookContents($book); - $booksInvolved = $bookContents->sortUsingMap($sortMap); + $booksInvolved = $sorter->sortUsingMap($sortMap); // Rebuild permissions and add activity for involved books. foreach ($booksInvolved as $bookInvolved) { diff --git a/app/Entities/Tools/BookSortMap.php b/app/Sorting/BookSortMap.php similarity index 96% rename from app/Entities/Tools/BookSortMap.php rename to app/Sorting/BookSortMap.php index ff1ec767f..96c9d342a 100644 --- a/app/Entities/Tools/BookSortMap.php +++ b/app/Sorting/BookSortMap.php @@ -1,6 +1,6 @@ loadModelsFromSortMap($sortMap); + + // Sort our changes from our map to be chapters first + // Since they need to be process to ensure book alignment for child page changes. + $sortMapItems = $sortMap->all(); + usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) { + $aScore = $itemA->type === 'page' ? 2 : 1; + $bScore = $itemB->type === 'page' ? 2 : 1; + + return $aScore - $bScore; + }); + + // Perform the sort + foreach ($sortMapItems as $item) { + $this->applySortUpdates($item, $modelMap); + } + + /** @var Book[] $booksInvolved */ + $booksInvolved = array_values(array_filter($modelMap, function (string $key) { + return str_starts_with($key, 'book:'); + }, ARRAY_FILTER_USE_KEY)); + + // Update permissions of books involved + foreach ($booksInvolved as $book) { + $book->rebuildPermissions(); + } + + return $booksInvolved; + } + + /** + * Using the given sort map item, detect changes for the related model + * and update it if required. Changes where permissions are lacking will + * be skipped and not throw an error. + * + * @param array $modelMap + */ + protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void + { + /** @var BookChild $model */ + $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null; + if (!$model) { + return; + } + + $priorityChanged = $model->priority !== $sortMapItem->sort; + $bookChanged = $model->book_id !== $sortMapItem->parentBookId; + $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId; + + // Stop if there's no change + if (!$priorityChanged && !$bookChanged && !$chapterChanged) { + return; + } + + $currentParentKey = 'book:' . $model->book_id; + if ($model instanceof Page && $model->chapter_id) { + $currentParentKey = 'chapter:' . $model->chapter_id; + } + + $currentParent = $modelMap[$currentParentKey] ?? null; + /** @var Book $newBook */ + $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null; + /** @var ?Chapter $newChapter */ + $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null; + + if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) { + return; + } + + // Action the required changes + if ($bookChanged) { + $model->changeBook($newBook->id); + } + + if ($model instanceof Page && $chapterChanged) { + $model->chapter_id = $newChapter->id ?? 0; + } + + if ($priorityChanged) { + $model->priority = $sortMapItem->sort; + } + + if ($chapterChanged || $priorityChanged) { + $model->save(); + } + } + + /** + * Check if the current user has permissions to apply the given sorting change. + * Is quite complex since items can gain a different parent change. Acts as a: + * - Update of old parent element (Change of content/order). + * - Update of sorted/moved element. + * - Deletion of element (Relative to parent upon move). + * - Creation of element within parent (Upon move to new parent). + */ + protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool + { + // Stop if we can't see the current parent or new book. + if (!$currentParent || !$newBook) { + return false; + } + + $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0)); + if ($model instanceof Chapter) { + $hasPermission = userCan('book-update', $currentParent) + && userCan('book-update', $newBook) + && userCan('chapter-update', $model) + && (!$hasNewParent || userCan('chapter-create', $newBook)) + && (!$hasNewParent || userCan('chapter-delete', $model)); + + if (!$hasPermission) { + return false; + } + } + + if ($model instanceof Page) { + $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update'; + $hasCurrentParentPermission = userCan($parentPermission, $currentParent); + + // This needs to check if there was an intended chapter location in the original sort map + // rather than inferring from the $newChapter since that variable may be null + // due to other reasons (Visibility). + $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook; + if (!$newParent) { + return false; + } + + $hasPageEditPermission = userCan('page-update', $model); + $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id)); + $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update'; + $hasNewParentPermission = userCan($newParentPermission, $newParent); + + $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model)); + $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent)); + + $hasPermission = $hasCurrentParentPermission + && $newParentInRightLocation + && $hasNewParentPermission + && $hasPageEditPermission + && $hasDeletePermissionIfMoving + && $hasCreatePermissionIfMoving; + + if (!$hasPermission) { + return false; + } + } + + return true; + } + + /** + * Load models from the database into the given sort map. + * + * @return array + */ + protected function loadModelsFromSortMap(BookSortMap $sortMap): array + { + $modelMap = []; + $ids = [ + 'chapter' => [], + 'page' => [], + 'book' => [], + ]; + + foreach ($sortMap->all() as $sortMapItem) { + $ids[$sortMapItem->type][] = $sortMapItem->id; + $ids['book'][] = $sortMapItem->parentBookId; + if ($sortMapItem->parentChapterId) { + $ids['chapter'][] = $sortMapItem->parentChapterId; + } + } + + $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get(); + /** @var Page $page */ + foreach ($pages as $page) { + $modelMap['page:' . $page->id] = $page; + $ids['book'][] = $page->book_id; + if ($page->chapter_id) { + $ids['chapter'][] = $page->chapter_id; + } + } + + $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get(); + /** @var Chapter $chapter */ + foreach ($chapters as $chapter) { + $modelMap['chapter:' . $chapter->id] = $chapter; + $ids['book'][] = $chapter->book_id; + } + + $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get(); + /** @var Book $book */ + foreach ($books as $book) { + $modelMap['book:' . $book->id] = $book; + } + + return $modelMap; + } +} diff --git a/routes/web.php b/routes/web.php index 5bb9622e7..e1e819dd0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use BookStack\Permissions\PermissionsController; use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; +use BookStack\Sorting\BookSortController; use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; @@ -66,7 +67,7 @@ Route::middleware('auth')->group(function () { Route::get('/books/{slug}/edit', [EntityControllers\BookController::class, 'edit']); Route::put('/books/{slug}', [EntityControllers\BookController::class, 'update']); Route::delete('/books/{id}', [EntityControllers\BookController::class, 'destroy']); - Route::get('/books/{slug}/sort-item', [EntityControllers\BookSortController::class, 'showItem']); + Route::get('/books/{slug}/sort-item', [BookSortController::class, 'showItem']); Route::get('/books/{slug}', [EntityControllers\BookController::class, 'show']); Route::get('/books/{bookSlug}/permissions', [PermissionsController::class, 'showForBook']); Route::put('/books/{bookSlug}/permissions', [PermissionsController::class, 'updateForBook']); @@ -74,8 +75,8 @@ Route::middleware('auth')->group(function () { Route::get('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'showCopy']); Route::post('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'copy']); Route::post('/books/{bookSlug}/convert-to-shelf', [EntityControllers\BookController::class, 'convertToShelf']); - Route::get('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'show']); - Route::put('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'update']); + Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); + Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); Route::get('/books/{slug}/references', [ReferenceController::class, 'book']); Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']); Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']); From 5b0cb3dd506c108b5d5d13c5c07c4f02e6107608 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Jan 2025 17:02:34 +0000 Subject: [PATCH 19/56] Sorting: Extracted URL sort helper to own class Was only used in one place, so didn't make sense to have extra global helper clutter. --- .../Controllers/AuditLogController.php | 2 + app/App/helpers.php | 32 ------------ app/Sorting/SortUrl.php | 49 +++++++++++++++++++ resources/views/settings/audit.blade.php | 4 +- 4 files changed, 53 insertions(+), 34 deletions(-) create mode 100644 app/Sorting/SortUrl.php diff --git a/app/Activity/Controllers/AuditLogController.php b/app/Activity/Controllers/AuditLogController.php index 641106d7f..66ca30197 100644 --- a/app/Activity/Controllers/AuditLogController.php +++ b/app/Activity/Controllers/AuditLogController.php @@ -5,6 +5,7 @@ namespace BookStack\Activity\Controllers; use BookStack\Activity\ActivityType; use BookStack\Activity\Models\Activity; use BookStack\Http\Controller; +use BookStack\Sorting\SortUrl; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; @@ -65,6 +66,7 @@ class AuditLogController extends Controller 'filters' => $filters, 'listOptions' => $listOptions, 'activityTypes' => $types, + 'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page'))) ]); } } diff --git a/app/App/helpers.php b/app/App/helpers.php index 941c267d6..204b3f06a 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -96,35 +96,3 @@ function theme_path(string $path = ''): ?string return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path)); } - -/** - * Generate a URL with multiple parameters for sorting purposes. - * Works out the logic to set the correct sorting direction - * Discards empty parameters and allows overriding. - */ -function sortUrl(string $path, array $data, array $overrideData = []): string -{ - $queryStringSections = []; - $queryData = array_merge($data, $overrideData); - - // Change sorting direction is already sorted on current attribute - if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) { - $queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc'; - } elseif (isset($overrideData['sort'])) { - $queryData['order'] = 'asc'; - } - - foreach ($queryData as $name => $value) { - $trimmedVal = trim($value); - if ($trimmedVal === '') { - continue; - } - $queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal); - } - - if (count($queryStringSections) === 0) { - return url($path); - } - - return url($path . '?' . implode('&', $queryStringSections)); -} diff --git a/app/Sorting/SortUrl.php b/app/Sorting/SortUrl.php new file mode 100644 index 000000000..f01df2c36 --- /dev/null +++ b/app/Sorting/SortUrl.php @@ -0,0 +1,49 @@ +path, $this->data, $overrideData); + } + + public function build(): string + { + $queryStringSections = []; + $queryData = array_merge($this->data, $this->overrideData); + + // Change sorting direction if already sorted on current attribute + if (isset($this->overrideData['sort']) && $this->overrideData['sort'] === $this->data['sort']) { + $queryData['order'] = ($this->data['order'] === 'asc') ? 'desc' : 'asc'; + } elseif (isset($this->overrideData['sort'])) { + $queryData['order'] = 'asc'; + } + + foreach ($queryData as $name => $value) { + $trimmedVal = trim($value); + if ($trimmedVal !== '') { + $queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal); + } + } + + if (count($queryStringSections) === 0) { + return url($this->path); + } + + return url($this->path . '?' . implode('&', $queryStringSections)); + } +} diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 28cdeb8a5..8e4776680 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -26,11 +26,11 @@ class="input-base text-left">{{ $filters['event'] ?: trans('settings.audit_event_filter_no_filter') }} From b2ac3e0834172e2eaf70d4c893c90704b5aa9bf8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Jan 2025 17:34:07 +0000 Subject: [PATCH 20/56] Sorting: Added SortSet model & migration --- app/Sorting/SortSetOption.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app/Sorting/SortSetOption.php diff --git a/app/Sorting/SortSetOption.php b/app/Sorting/SortSetOption.php new file mode 100644 index 000000000..0a78e99c7 --- /dev/null +++ b/app/Sorting/SortSetOption.php @@ -0,0 +1,16 @@ + Date: Thu, 30 Jan 2025 17:49:19 +0000 Subject: [PATCH 21/56] Sorting: Added content misses from last commit, started settings --- app/Sorting/SortSet.php | 35 +++++++++++++ ...25_01_29_180933_create_sort_sets_table.php | 29 +++++++++++ lang/en/settings.php | 7 +++ .../settings/categories/sorting.blade.php | 49 +++++++++++++++++++ resources/views/settings/layout.blade.php | 2 + 5 files changed, 122 insertions(+) create mode 100644 app/Sorting/SortSet.php create mode 100644 database/migrations/2025_01_29_180933_create_sort_sets_table.php create mode 100644 resources/views/settings/categories/sorting.blade.php diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php new file mode 100644 index 000000000..42e1e0951 --- /dev/null +++ b/app/Sorting/SortSet.php @@ -0,0 +1,35 @@ +sequence); + $options = array_map(fn ($val) => SortSetOption::tryFrom($val), $strOptions); + return array_filter($options); + } + + /** + * @param SortSetOption[] $options + */ + public function setOptions(array $options): void + { + $values = array_map(fn (SortSetOption $opt) => $opt->value, $options); + $this->sequence = implode(',', $values); + } +} diff --git a/database/migrations/2025_01_29_180933_create_sort_sets_table.php b/database/migrations/2025_01_29_180933_create_sort_sets_table.php new file mode 100644 index 000000000..bf9780c5b --- /dev/null +++ b/database/migrations/2025_01_29_180933_create_sort_sets_table.php @@ -0,0 +1,29 @@ +increments('id'); + $table->string('name'); + $table->text('sequence'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sort_sets'); + } +}; diff --git a/lang/en/settings.php b/lang/en/settings.php index c0b6b692a..b20152bfe 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -74,6 +74,13 @@ return [ 'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application.
Note that users will be able to change their email addresses after successful registration.', 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', + // Sorting Settings + 'sorting' => 'Sorting', + 'sorting_book_default' => 'Default Book Sort', + 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', + 'sorting_sets' => 'Sort Sets', + 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + // Maintenance settings 'maint' => 'Maintenance', 'maint_image_cleanup' => 'Cleanup Images', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php new file mode 100644 index 000000000..153ea0e3b --- /dev/null +++ b/resources/views/settings/categories/sorting.blade.php @@ -0,0 +1,49 @@ +@extends('settings.layout') + +@section('card') +

{{ trans('settings.sorting') }}

+
+ {{ csrf_field() }} + + +
+
+
+ +

{{ trans('settings.sorting_book_default_desc') }}

+
+
+ +
+
+ +
+ +
+ +
+
+@endsection + +@section('after-card') +
+

{{ trans('settings.sorting_sets') }}

+

{{ trans('settings.sorting_sets_desc') }}

+{{-- TODO--}} +
+@endsection \ No newline at end of file diff --git a/resources/views/settings/layout.blade.php b/resources/views/settings/layout.blade.php index a59b58d53..930d407a5 100644 --- a/resources/views/settings/layout.blade.php +++ b/resources/views/settings/layout.blade.php @@ -13,6 +13,7 @@ @icon('star') {{ trans('settings.app_features_security') }} @icon('palette') {{ trans('settings.app_customization') }} @icon('security') {{ trans('settings.reg_settings') }} + @icon('sort') {{ trans('settings.sorting') }}
{{ trans('settings.system_version') }}
@@ -29,6 +30,7 @@
@yield('card')
+ @yield('after-card') From 4f5f7c10b10bfa0434cbe4c81a066cf779ca63e5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 31 Jan 2025 21:29:38 +0000 Subject: [PATCH 22/56] Thumbnails: Fixed thumnail orientation Prevents double rotation caused from both our own orientation handling upon that invervention was auto-applying since v3. Fixes #5462 --- app/Uploads/ImageResizer.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/Uploads/ImageResizer.php b/app/Uploads/ImageResizer.php index fa6b1cac2..5f095658f 100644 --- a/app/Uploads/ImageResizer.php +++ b/app/Uploads/ImageResizer.php @@ -158,7 +158,10 @@ class ImageResizer */ protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage { - $manager = new ImageManager(new Driver()); + $manager = new ImageManager( + new Driver(), + autoOrientation: false, + ); // Ensure gif images are decoded natively instead of deferring to intervention GIF // handling since we don't need the added animation support. From bf8a84a8b1dd02578e6b5e2b39882902809f112a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 3 Feb 2025 16:48:57 +0000 Subject: [PATCH 23/56] Sorting: Started sort set routes and form --- app/Sorting/SortSetController.php | 19 +++++ app/Sorting/SortSetOption.php | 28 +++++++ lang/en/settings.php | 16 ++++ .../settings/categories/sorting.blade.php | 12 ++- .../views/settings/sort-sets/create.blade.php | 24 ++++++ .../settings/sort-sets/parts/form.blade.php | 74 +++++++++++++++++++ routes/web.php | 15 +++- 7 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 app/Sorting/SortSetController.php create mode 100644 resources/views/settings/sort-sets/create.blade.php create mode 100644 resources/views/settings/sort-sets/parts/form.blade.php diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php new file mode 100644 index 000000000..4ef148295 --- /dev/null +++ b/app/Sorting/SortSetController.php @@ -0,0 +1,19 @@ +middleware('can:settings-manage'); + // TODO - Test + } + + public function create() + { + return view('settings.sort-sets.create'); + } +} diff --git a/app/Sorting/SortSetOption.php b/app/Sorting/SortSetOption.php index 0a78e99c7..bb878cf30 100644 --- a/app/Sorting/SortSetOption.php +++ b/app/Sorting/SortSetOption.php @@ -13,4 +13,32 @@ enum SortSetOption: string case UpdateDateDesc = 'updated_date_desc'; case ChaptersFirst = 'chapters_first'; case ChaptersLast = 'chapters_last'; + + /** + * Provide a translated label string for this option. + */ + public function getLabel(): string + { + $key = $this->value; + $label = ''; + if (str_ends_with($key, '_asc')) { + $key = substr($key, 0, -4); + $label = trans('settings.sort_set_op_asc'); + } elseif (str_ends_with($key, '_desc')) { + $key = substr($key, 0, -5); + $label = trans('settings.sort_set_op_desc'); + } + + $label = trans('settings.sort_set_op_' . $key) . ' ' . $label; + return trim($label); + } + + /** + * @return SortSetOption[] + */ + public static function allExcluding(array $options): array + { + $all = SortSetOption::cases(); + return array_diff($all, $options); + } } diff --git a/lang/en/settings.php b/lang/en/settings.php index b20152bfe..b29ec2533 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -80,6 +80,22 @@ return [ 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_sets' => 'Sort Sets', 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + 'sort_set_create' => 'Create Sort Set', + 'sort_set_edit' => 'Edit Sort Set', + 'sort_set_details' => 'Sort Set Details', + 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', + 'sort_set_operations' => 'Sort Operations', + 'sort_set_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', + 'sort_set_available_operations' => 'Available Operations', + 'sort_set_configured_operations' => 'Configured Operations', + 'sort_set_op_asc' => '(Asc)', + 'sort_set_op_desc' => '(Desc)', + 'sort_set_op_name' => 'Name - Alphabetical', + 'sort_set_op_name_numeric' => 'Name - Numeric', + 'sort_set_op_created_date' => 'Created Date', + 'sort_set_op_updated_date' => 'Updated Date', + 'sort_set_op_chapters_first' => 'Chapters First', + 'sort_set_op_chapters_last' => 'Chapters Last', // Maintenance settings 'maint' => 'Maintenance', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 153ea0e3b..9de11bb6f 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -42,8 +42,16 @@ @section('after-card')
-

{{ trans('settings.sorting_sets') }}

-

{{ trans('settings.sorting_sets_desc') }}

+
+
+

{{ trans('settings.sorting_sets') }}

+

{{ trans('settings.sorting_sets_desc') }}

+
+ +
+ {{-- TODO--}}
@endsection \ No newline at end of file diff --git a/resources/views/settings/sort-sets/create.blade.php b/resources/views/settings/sort-sets/create.blade.php new file mode 100644 index 000000000..16f2d2ac7 --- /dev/null +++ b/resources/views/settings/sort-sets/create.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.simple') + +@section('body') + +
+ + @include('settings.parts.navbar', ['selected' => 'settings']) + +
+

{{ trans('settings.sort_set_create') }}

+ +
+ {{ csrf_field() }} + @include('settings.sort-sets.parts.form', ['model' => null]) + +
+ {{ trans('common.cancel') }} + +
+
+
+
+ +@stop diff --git a/resources/views/settings/sort-sets/parts/form.blade.php b/resources/views/settings/sort-sets/parts/form.blade.php new file mode 100644 index 000000000..6df04a721 --- /dev/null +++ b/resources/views/settings/sort-sets/parts/form.blade.php @@ -0,0 +1,74 @@ + +
+
+
+ +

{{ trans('settings.sort_set_details_desc') }}

+
+
+
+ + @include('form.text', ['name' => 'name']) +
+
+
+ +
+ +

{{ trans('settings.sort_set_operations_desc') }}

+ + + +
+
+ +
    + @foreach(($model?->getOptions() ?? []) as $option) +
  • +
    @icon('grip')
    +
    {{ $option->getLabel() }}
    +
    + + + + +
    +
  • + @endforeach +
+
+ +
+ +
    + @foreach(\BookStack\Sorting\SortSetOption::allExcluding($model?->getOptions() ?? []) as $option) +
  • +
    @icon('grip')
    +
    {{ $option->getLabel() }}
    +
    + + + + +
    +
  • + @endforeach +
+
+
+
+
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index e1e819dd0..62c120f20 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,7 +13,7 @@ use BookStack\Permissions\PermissionsController; use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; -use BookStack\Sorting\BookSortController; +use BookStack\Sorting as SortingControllers; use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; @@ -67,7 +67,7 @@ Route::middleware('auth')->group(function () { Route::get('/books/{slug}/edit', [EntityControllers\BookController::class, 'edit']); Route::put('/books/{slug}', [EntityControllers\BookController::class, 'update']); Route::delete('/books/{id}', [EntityControllers\BookController::class, 'destroy']); - Route::get('/books/{slug}/sort-item', [BookSortController::class, 'showItem']); + Route::get('/books/{slug}/sort-item', [SortingControllers\BookSortController::class, 'showItem']); Route::get('/books/{slug}', [EntityControllers\BookController::class, 'show']); Route::get('/books/{bookSlug}/permissions', [PermissionsController::class, 'showForBook']); Route::put('/books/{bookSlug}/permissions', [PermissionsController::class, 'updateForBook']); @@ -75,8 +75,8 @@ Route::middleware('auth')->group(function () { Route::get('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'showCopy']); Route::post('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'copy']); Route::post('/books/{bookSlug}/convert-to-shelf', [EntityControllers\BookController::class, 'convertToShelf']); - Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); - Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); + Route::get('/books/{bookSlug}/sort', [SortingControllers\BookSortController::class, 'show']); + Route::put('/books/{bookSlug}/sort', [SortingControllers\BookSortController::class, 'update']); Route::get('/books/{slug}/references', [ReferenceController::class, 'book']); Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']); Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']); @@ -295,6 +295,13 @@ Route::middleware('auth')->group(function () { Route::get('/settings/webhooks/{id}/delete', [ActivityControllers\WebhookController::class, 'delete']); Route::delete('/settings/webhooks/{id}', [ActivityControllers\WebhookController::class, 'destroy']); + // Sort Sets + Route::get('/settings/sorting/sets/new', [SortingControllers\SortSetController::class, 'create']); + Route::post('/settings/sorting/sets', [SortingControllers\SortSetController::class, 'store']); + Route::get('/settings/sorting/sets/{id}', [SortingControllers\SortSetController::class, 'edit']); + Route::put('/settings/sorting/sets/{id}', [SortingControllers\SortSetController::class, 'update']); + Route::delete('/settings/sorting/sets/{id}', [SortingControllers\SortSetController::class, 'destroy']); + // Settings Route::get('/settings', [SettingControllers\SettingController::class, 'index'])->name('settings'); Route::get('/settings/{category}', [SettingControllers\SettingController::class, 'category'])->name('settings.category'); From 12cc2f0689306048b7f13e4d04577da255d96583 Mon Sep 17 00:00:00 2001 From: Silverlan Date: Mon, 3 Feb 2025 19:01:08 +0100 Subject: [PATCH 24/56] Fix incorrect condition for displaying new books section --- resources/views/books/index.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 418c0fea8..197de011d 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -23,7 +23,7 @@
{{ trans('entities.books_new') }}
- @if(count($popular) > 0) + @if(count($new) > 0) @include('entities.list', ['entities' => $new, 'style' => 'compact']) @else

{{ trans('entities.books_new_empty') }}

@@ -59,4 +59,4 @@
-@stop \ No newline at end of file +@stop From d28278bba63eaa13d7ab691379b4b741c1fb83e6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 4 Feb 2025 15:14:22 +0000 Subject: [PATCH 25/56] Sorting: Added sort set form manager UI JS Extracted much code to be shared with the shelf books management UI --- app/Sorting/SortSet.php | 12 ++-- ...SortSetOption.php => SortSetOperation.php} | 10 ++-- lang/en/settings.php | 2 + package-lock.json | 9 ++- package.json | 1 + resources/js/components/index.ts | 1 + resources/js/components/shelf-sort.js | 48 ++------------- resources/js/components/sort-set-manager.ts | 41 +++++++++++++ resources/js/services/dual-lists.ts | 51 ++++++++++++++++ resources/sass/_components.scss | 19 ++++-- .../settings/sort-sets/parts/form.blade.php | 58 ++++++------------- .../sort-sets/parts/operation.blade.php | 15 +++++ resources/views/shelves/parts/form.blade.php | 4 +- 13 files changed, 168 insertions(+), 103 deletions(-) rename app/Sorting/{SortSetOption.php => SortSetOperation.php} (82%) create mode 100644 resources/js/components/sort-set-manager.ts create mode 100644 resources/js/services/dual-lists.ts create mode 100644 resources/views/settings/sort-sets/parts/operation.blade.php diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index 42e1e0951..ee45c211f 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -15,21 +15,21 @@ use Illuminate\Database\Eloquent\Model; class SortSet extends Model { /** - * @return SortSetOption[] + * @return SortSetOperation[] */ - public function getOptions(): array + public function getOperations(): array { $strOptions = explode(',', $this->sequence); - $options = array_map(fn ($val) => SortSetOption::tryFrom($val), $strOptions); + $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); return array_filter($options); } /** - * @param SortSetOption[] $options + * @param SortSetOperation[] $options */ - public function setOptions(array $options): void + public function setOperations(array $options): void { - $values = array_map(fn (SortSetOption $opt) => $opt->value, $options); + $values = array_map(fn (SortSetOperation $opt) => $opt->value, $options); $this->sequence = implode(',', $values); } } diff --git a/app/Sorting/SortSetOption.php b/app/Sorting/SortSetOperation.php similarity index 82% rename from app/Sorting/SortSetOption.php rename to app/Sorting/SortSetOperation.php index bb878cf30..12fda669f 100644 --- a/app/Sorting/SortSetOption.php +++ b/app/Sorting/SortSetOperation.php @@ -2,7 +2,7 @@ namespace BookStack\Sorting; -enum SortSetOption: string +enum SortSetOperation: string { case NameAsc = 'name_asc'; case NameDesc = 'name_desc'; @@ -34,11 +34,11 @@ enum SortSetOption: string } /** - * @return SortSetOption[] + * @return SortSetOperation[] */ - public static function allExcluding(array $options): array + public static function allExcluding(array $operations): array { - $all = SortSetOption::cases(); - return array_diff($all, $options); + $all = SortSetOperation::cases(); + return array_diff($all, $operations); } } diff --git a/lang/en/settings.php b/lang/en/settings.php index b29ec2533..8bb2f6ef4 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -87,7 +87,9 @@ return [ 'sort_set_operations' => 'Sort Operations', 'sort_set_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', 'sort_set_available_operations' => 'Available Operations', + 'sort_set_available_operations_empty' => 'No operations remaining', 'sort_set_configured_operations' => 'Configured Operations', + 'sort_set_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', 'sort_set_op_asc' => '(Asc)', 'sort_set_op_desc' => '(Desc)', 'sort_set_op_name' => 'Name - Alphabetical', diff --git a/package-lock.json b/package-lock.json index 1912106c2..44a735d2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "bookstack", "dependencies": { "@codemirror/commands": "^6.7.1", "@codemirror/lang-css": "^6.3.1", @@ -32,6 +31,7 @@ }, "devDependencies": { "@lezer/generator": "^1.7.2", + "@types/sortablejs": "^1.15.8", "chokidar-cli": "^3.0", "esbuild": "^0.24.0", "eslint": "^8.57.1", @@ -2403,6 +2403,13 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/sortablejs": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz", + "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", diff --git a/package.json b/package.json index 08af25d14..4571ea77d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@lezer/generator": "^1.7.2", + "@types/sortablejs": "^1.15.8", "chokidar-cli": "^3.0", "esbuild": "^0.24.0", "eslint": "^8.57.1", diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 12c991a51..affa25fcf 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -50,6 +50,7 @@ export {ShelfSort} from './shelf-sort'; export {Shortcuts} from './shortcuts'; export {ShortcutInput} from './shortcut-input'; export {SortableList} from './sortable-list'; +export {SortSetManager} from './sort-set-manager' export {SubmitOnChange} from './submit-on-change'; export {Tabs} from './tabs'; export {TagManager} from './tag-manager'; diff --git a/resources/js/components/shelf-sort.js b/resources/js/components/shelf-sort.js index 01ca11a33..b56b01980 100644 --- a/resources/js/components/shelf-sort.js +++ b/resources/js/components/shelf-sort.js @@ -1,29 +1,6 @@ import Sortable from 'sortablejs'; import {Component} from './component'; - -/** - * @type {Object} - */ -const itemActions = { - move_up(item) { - const list = item.parentNode; - const index = Array.from(list.children).indexOf(item); - const newIndex = Math.max(index - 1, 0); - list.insertBefore(item, list.children[newIndex] || null); - }, - move_down(item) { - const list = item.parentNode; - const index = Array.from(list.children).indexOf(item); - const newIndex = Math.min(index + 2, list.children.length); - list.insertBefore(item, list.children[newIndex] || null); - }, - remove(item, shelfBooksList, allBooksList) { - allBooksList.appendChild(item); - }, - add(item, shelfBooksList) { - shelfBooksList.appendChild(item); - }, -}; +import {buildListActions, sortActionClickListener} from '../services/dual-lists.ts'; export class ShelfSort extends Component { @@ -55,12 +32,9 @@ export class ShelfSort extends Component { } setupListeners() { - this.elem.addEventListener('click', event => { - const sortItemAction = event.target.closest('.scroll-box-item button[data-action]'); - if (sortItemAction) { - this.sortItemActionClick(sortItemAction); - } - }); + const listActions = buildListActions(this.allBookList, this.shelfBookList); + const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this)); + this.elem.addEventListener('click', sortActionListener); this.bookSearchInput.addEventListener('input', () => { this.filterBooksByName(this.bookSearchInput.value); @@ -93,20 +67,6 @@ export class ShelfSort extends Component { } } - /** - * Called when a sort item action button is clicked. - * @param {HTMLElement} sortItemAction - */ - sortItemActionClick(sortItemAction) { - const sortItem = sortItemAction.closest('.scroll-box-item'); - const {action} = sortItemAction.dataset; - - const actionFunction = itemActions[action]; - actionFunction(sortItem, this.shelfBookList, this.allBookList); - - this.onChange(); - } - onChange() { const shelfBookElems = Array.from(this.shelfBookList.querySelectorAll('[data-id]')); this.input.value = shelfBookElems.map(elem => elem.getAttribute('data-id')).join(','); diff --git a/resources/js/components/sort-set-manager.ts b/resources/js/components/sort-set-manager.ts new file mode 100644 index 000000000..c35ad41fe --- /dev/null +++ b/resources/js/components/sort-set-manager.ts @@ -0,0 +1,41 @@ +import {Component} from "./component.js"; +import Sortable from "sortablejs"; +import {buildListActions, sortActionClickListener} from "../services/dual-lists"; + + +export class SortSetManager extends Component { + + protected input!: HTMLInputElement; + protected configuredList!: HTMLElement; + protected availableList!: HTMLElement; + + setup() { + this.input = this.$refs.input as HTMLInputElement; + this.configuredList = this.$refs.configuredOperationsList; + this.availableList = this.$refs.availableOperationsList; + + this.initSortable(); + + const listActions = buildListActions(this.availableList, this.configuredList); + const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this)); + this.$el.addEventListener('click', sortActionListener); + } + + initSortable() { + const scrollBoxes = [this.configuredList, this.availableList]; + for (const scrollBox of scrollBoxes) { + new Sortable(scrollBox, { + group: 'sort-set-operations', + ghostClass: 'primary-background-light', + handle: '.handle', + animation: 150, + onSort: this.onChange.bind(this), + }); + } + } + + onChange() { + const configuredOpEls = Array.from(this.configuredList.querySelectorAll('[data-id]')); + this.input.value = configuredOpEls.map(elem => elem.getAttribute('data-id')).join(','); + } +} \ No newline at end of file diff --git a/resources/js/services/dual-lists.ts b/resources/js/services/dual-lists.ts new file mode 100644 index 000000000..98f2af92d --- /dev/null +++ b/resources/js/services/dual-lists.ts @@ -0,0 +1,51 @@ +/** + * Service for helping manage common dual-list scenarios. + * (Shelf book manager, sort set manager). + */ + +type ListActionsSet = Record void)>; + +export function buildListActions( + availableList: HTMLElement, + configuredList: HTMLElement, +): ListActionsSet { + return { + move_up(item) { + const list = item.parentNode as HTMLElement; + const index = Array.from(list.children).indexOf(item); + const newIndex = Math.max(index - 1, 0); + list.insertBefore(item, list.children[newIndex] || null); + }, + move_down(item) { + const list = item.parentNode as HTMLElement; + const index = Array.from(list.children).indexOf(item); + const newIndex = Math.min(index + 2, list.children.length); + list.insertBefore(item, list.children[newIndex] || null); + }, + remove(item) { + availableList.appendChild(item); + }, + add(item) { + configuredList.appendChild(item); + }, + }; +} + +export function sortActionClickListener(actions: ListActionsSet, onChange: () => void) { + return (event: MouseEvent) => { + const sortItemAction = (event.target as Element).closest('.scroll-box-item button[data-action]') as HTMLElement|null; + if (sortItemAction) { + const sortItem = sortItemAction.closest('.scroll-box-item') as HTMLElement; + const action = sortItemAction.dataset.action; + if (!action) { + throw new Error('No action defined for clicked button'); + } + + const actionFunction = actions[action]; + actionFunction(sortItem); + + onChange(); + } + }; +} + diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 888b32527..58d39d3ee 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1062,12 +1062,16 @@ $btt-size: 40px; cursor: pointer; @include mixins.lightDark(background-color, #f8f8f8, #333); } + &.items-center { + align-items: center; + } .handle { color: #AAA; cursor: grab; } button { opacity: .6; + line-height: 1; } .handle svg { margin: 0; @@ -1108,12 +1112,19 @@ input.scroll-box-search, .scroll-box-header-item { border-radius: 0 0 3px 3px; } -.scroll-box[refs="shelf-sort@shelf-book-list"] [data-action="add"] { +.scroll-box.configured-option-list [data-action="add"] { display: none; } -.scroll-box[refs="shelf-sort@all-book-list"] [data-action="remove"], -.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_up"], -.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_down"], +.scroll-box.available-option-list [data-action="remove"], +.scroll-box.available-option-list [data-action="move_up"], +.scroll-box.available-option-list [data-action="move_down"], { display: none; +} + +.scroll-box > li.empty-state { + display: none; +} +.scroll-box > li.empty-state:last-child { + display: list-item; } \ No newline at end of file diff --git a/resources/views/settings/sort-sets/parts/form.blade.php b/resources/views/settings/sort-sets/parts/form.blade.php index 6df04a721..3f2220947 100644 --- a/resources/views/settings/sort-sets/parts/form.blade.php +++ b/resources/views/settings/sort-sets/parts/form.blade.php @@ -1,4 +1,3 @@ -
@@ -13,59 +12,36 @@
-
+

{{ trans('settings.sort_set_operations_desc') }}

- +
- -
    {{ trans('settings.sort_set_configured_operations') }} +
      - @foreach(($model?->getOptions() ?? []) as $option) -
    • -
      @icon('grip')
      -
      {{ $option->getLabel() }}
      -
      - - - - -
      -
    • + class="scroll-box configured-option-list"> +
    • {{ trans('settings.sort_set_configured_operations_empty') }}
    • + @foreach(($model?->getOperations() ?? []) as $option) + @include('settings.sort-sets.parts.operation') @endforeach
- -
    {{ trans('settings.sort_set_available_operations') }} +
      - @foreach(\BookStack\Sorting\SortSetOption::allExcluding($model?->getOptions() ?? []) as $option) -
    • -
      @icon('grip')
      -
      {{ $option->getLabel() }}
      -
      - - - - -
      -
    • + class="scroll-box available-option-list"> +
    • {{ trans('settings.sort_set_available_operations_empty') }}
    • + @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($model?->getOperations() ?? []) as $operation) + @include('settings.sort-sets.parts.operation', ['operation' => $operation]) @endforeach
diff --git a/resources/views/settings/sort-sets/parts/operation.blade.php b/resources/views/settings/sort-sets/parts/operation.blade.php new file mode 100644 index 000000000..3feb68a47 --- /dev/null +++ b/resources/views/settings/sort-sets/parts/operation.blade.php @@ -0,0 +1,15 @@ +
  • +
    @icon('grip')
    +
    {{ $operation->getLabel() }}
    +
    + + + + +
    +
  • \ No newline at end of file diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php index a75dd6ac1..7790ba5a4 100644 --- a/resources/views/shelves/parts/form.blade.php +++ b/resources/views/shelves/parts/form.blade.php @@ -38,7 +38,7 @@
      + class="scroll-box configured-option-list"> @foreach (($shelf->visibleBooks ?? []) as $book) @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) @endforeach @@ -49,7 +49,7 @@
        + class="scroll-box available-option-list"> @foreach ($books as $book) @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) @endforeach From b897af2ed034088193986c8526be9606edaca7d5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 4 Feb 2025 20:11:35 +0000 Subject: [PATCH 26/56] Sorting: Finished main sort set CRUD work --- app/Activity/ActivityType.php | 4 ++ app/Sorting/SortSet.php | 17 +++-- app/Sorting/SortSetController.php | 69 +++++++++++++++++++ app/Sorting/SortSetOperation.php | 17 ++++- lang/en/activities.php | 8 +++ lang/en/settings.php | 2 + .../settings/categories/sorting.blade.php | 13 +++- .../views/settings/sort-sets/edit.blade.php | 44 ++++++++++++ .../settings/sort-sets/parts/form.blade.php | 16 +++-- .../parts/sort-set-list-item.blade.php | 8 +++ 10 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 resources/views/settings/sort-sets/edit.blade.php create mode 100644 resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index 5ec9b9cf0..4a648da6c 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -71,6 +71,10 @@ class ActivityType const IMPORT_RUN = 'import_run'; const IMPORT_DELETE = 'import_delete'; + const SORT_SET_CREATE = 'sort_set_create'; + const SORT_SET_UPDATE = 'sort_set_update'; + const SORT_SET_DELETE = 'sort_set_delete'; + /** * Get all the possible values. */ diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index ee45c211f..971b3e29a 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -2,6 +2,7 @@ namespace BookStack\Sorting; +use BookStack\Activity\Models\Loggable; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; @@ -12,16 +13,14 @@ use Illuminate\Database\Eloquent\Model; * @property Carbon $created_at * @property Carbon $updated_at */ -class SortSet extends Model +class SortSet extends Model implements Loggable { /** * @return SortSetOperation[] */ public function getOperations(): array { - $strOptions = explode(',', $this->sequence); - $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); - return array_filter($options); + return SortSetOperation::fromSequence($this->sequence); } /** @@ -32,4 +31,14 @@ class SortSet extends Model $values = array_map(fn (SortSetOperation $opt) => $opt->value, $options); $this->sequence = implode(',', $values); } + + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } + + public function getUrl(): string + { + return url("/settings/sorting/sets/{$this->id}"); + } } diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index 4ef148295..0d77bd88f 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -2,7 +2,9 @@ namespace BookStack\Sorting; +use BookStack\Activity\ActivityType; use BookStack\Http\Controller; +use BookStack\Http\Request; class SortSetController extends Controller { @@ -14,6 +16,73 @@ class SortSetController extends Controller public function create() { + $this->setPageTitle(trans('settings.sort_set_create')); + return view('settings.sort-sets.create'); } + + public function store(Request $request) + { + $this->validate($request, [ + 'name' => ['required', 'string', 'min:1', 'max:200'], + 'sequence' => ['required', 'string', 'min:1'], + ]); + + $operations = SortSetOperation::fromSequence($request->input('sequence')); + if (count($operations) === 0) { + return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); + } + + $set = new SortSet(); + $set->name = $request->input('name'); + $set->setOperations($operations); + $set->save(); + + $this->logActivity(ActivityType::SORT_SET_CREATE, $set); + + return redirect('/settings/sorting'); + } + + public function edit(string $id) + { + $set = SortSet::query()->findOrFail($id); + + $this->setPageTitle(trans('settings.sort_set_edit')); + + return view('settings.sort-sets.edit', ['set' => $set]); + } + + public function update(string $id, Request $request) + { + $this->validate($request, [ + 'name' => ['required', 'string', 'min:1', 'max:200'], + 'sequence' => ['required', 'string', 'min:1'], + ]); + + $set = SortSet::query()->findOrFail($id); + $operations = SortSetOperation::fromSequence($request->input('sequence')); + if (count($operations) === 0) { + return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); + } + + $set->name = $request->input('name'); + $set->setOperations($operations); + $set->save(); + + $this->logActivity(ActivityType::SORT_SET_UPDATE, $set); + + return redirect('/settings/sorting'); + } + + public function destroy(string $id) + { + $set = SortSet::query()->findOrFail($id); + + // TODO - Check if it's in use + + $set->delete(); + $this->logActivity(ActivityType::SORT_SET_DELETE, $set); + + return redirect('/settings/sorting'); + } } diff --git a/app/Sorting/SortSetOperation.php b/app/Sorting/SortSetOperation.php index 12fda669f..a6dd860f5 100644 --- a/app/Sorting/SortSetOperation.php +++ b/app/Sorting/SortSetOperation.php @@ -39,6 +39,21 @@ enum SortSetOperation: string public static function allExcluding(array $operations): array { $all = SortSetOperation::cases(); - return array_diff($all, $operations); + $filtered = array_filter($all, function (SortSetOperation $operation) use ($operations) { + return !in_array($operation, $operations); + }); + return array_values($filtered); + } + + /** + * Create a set of operations from a string sequence representation. + * (values seperated by commas). + * @return SortSetOperation[] + */ + public static function fromSequence(string $sequence): array + { + $strOptions = explode(',', $sequence); + $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); + return array_filter($options); } } diff --git a/lang/en/activities.php b/lang/en/activities.php index 7c3454d41..7db872c0c 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -127,6 +127,14 @@ return [ 'comment_update' => 'updated comment', 'comment_delete' => 'deleted comment', + // Sort Sets + 'sort_set_create' => 'created sort set', + 'sort_set_create_notification' => 'Sort set successfully created', + 'sort_set_update' => 'updated sort set', + 'sort_set_update_notification' => 'Sort set successfully update', + 'sort_set_delete' => 'deleted sort set', + 'sort_set_delete_notification' => 'Sort set successfully deleted', + // Other 'permissions_update' => 'updated permissions', ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 8bb2f6ef4..cda097590 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -82,6 +82,8 @@ return [ 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', 'sort_set_create' => 'Create Sort Set', 'sort_set_edit' => 'Edit Sort Set', + 'sort_set_delete' => 'Delete Sort Set', + 'sort_set_delete_desc' => 'Remove this sort set from the system. Deletion will only go ahead if the sort is not in active use.', 'sort_set_details' => 'Sort Set Details', 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', 'sort_set_operations' => 'Sort Operations', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 9de11bb6f..b5d613840 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -52,6 +52,17 @@
    -{{-- TODO--}} + @php + $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); + @endphp + @if(empty($sortSets)) +

    {{ trans('common.no_items') }}

    + @else +
    + @foreach($sortSets as $set) + @include('settings.sort-sets.parts.sort-set-list-item', ['set' => $set]) + @endforeach +
    + @endif
    @endsection \ No newline at end of file diff --git a/resources/views/settings/sort-sets/edit.blade.php b/resources/views/settings/sort-sets/edit.blade.php new file mode 100644 index 000000000..3b88c1243 --- /dev/null +++ b/resources/views/settings/sort-sets/edit.blade.php @@ -0,0 +1,44 @@ +@extends('layouts.simple') + +@section('body') + +
    + + @include('settings.parts.navbar', ['selected' => 'settings']) + +
    +

    {{ trans('settings.sort_set_edit') }}

    + +
    + {{ method_field('PUT') }} + {{ csrf_field() }} + + @include('settings.sort-sets.parts.form', ['model' => $set]) + +
    + {{ trans('common.cancel') }} + +
    +
    +
    + +
    +
    +
    +

    {{ trans('settings.sort_set_delete') }}

    +

    {{ trans('settings.sort_set_delete_desc') }}

    +
    +
    +
    + {{ method_field('DELETE') }} + {{ csrf_field() }} +
    + +
    +
    +
    +
    +
    +
    + +@stop diff --git a/resources/views/settings/sort-sets/parts/form.blade.php b/resources/views/settings/sort-sets/parts/form.blade.php index 3f2220947..38db840ac 100644 --- a/resources/views/settings/sort-sets/parts/form.blade.php +++ b/resources/views/settings/sort-sets/parts/form.blade.php @@ -15,9 +15,14 @@

    {{ trans('settings.sort_set_operations_desc') }}

    + @include('form.errors', ['name' => 'sequence']) - + + + @php + $configuredOps = old('sequence') ? \BookStack\Sorting\SortSetOperation::fromSequence(old('sequence')) : ($model?->getOperations() ?? []); + @endphp
    @@ -27,8 +32,9 @@ aria-labelledby="sort-set-configured-operations" class="scroll-box configured-option-list">
  • {{ trans('settings.sort_set_configured_operations_empty') }}
  • - @foreach(($model?->getOperations() ?? []) as $option) - @include('settings.sort-sets.parts.operation') + + @foreach($configuredOps as $operation) + @include('settings.sort-sets.parts.operation', ['operation' => $operation]) @endforeach
    @@ -40,7 +46,7 @@ aria-labelledby="sort-set-available-operations" class="scroll-box available-option-list">
  • {{ trans('settings.sort_set_available_operations_empty') }}
  • - @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($model?->getOperations() ?? []) as $operation) + @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($configuredOps) as $operation) @include('settings.sort-sets.parts.operation', ['operation' => $operation]) @endforeach diff --git a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php new file mode 100644 index 000000000..e5ee1fb87 --- /dev/null +++ b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php @@ -0,0 +1,8 @@ +
    + +
    + {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $set->getOperations())) }} +
    +
    \ No newline at end of file From 7093daa49de63e237d442709a93a03b5acc4f323 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Feb 2025 14:33:46 +0000 Subject: [PATCH 27/56] Sorting: Connected up default sort setting for books --- app/Entities/Models/Book.php | 11 ++++++++ app/Entities/Repos/BookRepo.php | 7 +++++ app/Sorting/SortSet.php | 7 +++++ app/Sorting/SortSetController.php | 13 +++++++-- ..._02_05_150842_add_sort_set_id_to_books.php | 28 +++++++++++++++++++ lang/en/settings.php | 2 ++ .../settings/categories/sorting.blade.php | 23 ++++++++------- 7 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index c1644dcf5..7d240e5ca 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -2,6 +2,7 @@ namespace BookStack\Entities\Models; +use BookStack\Sorting\SortSet; use BookStack\Uploads\Image; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -16,12 +17,14 @@ use Illuminate\Support\Collection; * @property string $description * @property int $image_id * @property ?int $default_template_id + * @property ?int $sort_set_id * @property Image|null $cover * @property \Illuminate\Database\Eloquent\Collection $chapters * @property \Illuminate\Database\Eloquent\Collection $pages * @property \Illuminate\Database\Eloquent\Collection $directPages * @property \Illuminate\Database\Eloquent\Collection $shelves * @property ?Page $defaultTemplate + * @property ?SortSet $sortSet */ class Book extends Entity implements HasCoverImage { @@ -82,6 +85,14 @@ class Book extends Entity implements HasCoverImage return $this->belongsTo(Page::class, 'default_template_id'); } + /** + * Get the sort set assigned to this book, if existing. + */ + public function sortSet(): BelongsTo + { + return $this->belongsTo(SortSet::class); + } + /** * Get all pages within this book. */ diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 19d159eb1..b3b811647 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -8,6 +8,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\ImageUploadException; use BookStack\Facades\Activity; +use BookStack\Sorting\SortSet; use BookStack\Uploads\ImageRepo; use Exception; use Illuminate\Http\UploadedFile; @@ -33,6 +34,12 @@ class BookRepo $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null)); Activity::add(ActivityType::BOOK_CREATE, $book); + $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); + if ($defaultBookSortSetting && SortSet::query()->find($defaultBookSortSetting)) { + $book->sort_set_id = $defaultBookSortSetting; + $book->save(); + } + return $book; } diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index 971b3e29a..a73407bfa 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -3,8 +3,10 @@ namespace BookStack\Sorting; use BookStack\Activity\Models\Loggable; +use BookStack\Entities\Models\Book; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id @@ -41,4 +43,9 @@ class SortSet extends Model implements Loggable { return url("/settings/sorting/sets/{$this->id}"); } + + public function books(): HasMany + { + return $this->hasMany(Book::class); + } } diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index 0d77bd88f..8f5120791 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -62,7 +62,7 @@ class SortSetController extends Controller $set = SortSet::query()->findOrFail($id); $operations = SortSetOperation::fromSequence($request->input('sequence')); if (count($operations) === 0) { - return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); + return redirect($set->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']); } $set->name = $request->input('name'); @@ -78,7 +78,16 @@ class SortSetController extends Controller { $set = SortSet::query()->findOrFail($id); - // TODO - Check if it's in use + if ($set->books()->count() > 0) { + $this->showErrorNotification(trans('settings.sort_set_delete_fail_books')); + return redirect($set->getUrl()); + } + + $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); + if ($defaultBookSortSetting === intval($id)) { + $this->showErrorNotification(trans('settings.sort_set_delete_fail_default')); + return redirect($set->getUrl()); + } $set->delete(); $this->logActivity(ActivityType::SORT_SET_DELETE, $set); diff --git a/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php b/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php new file mode 100644 index 000000000..c0b32c552 --- /dev/null +++ b/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php @@ -0,0 +1,28 @@ +unsignedInteger('sort_set_id')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('books', function (Blueprint $table) { + $table->dropColumn('sort_set_id'); + }); + } +}; diff --git a/lang/en/settings.php b/lang/en/settings.php index cda097590..eb046d278 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -84,6 +84,8 @@ return [ 'sort_set_edit' => 'Edit Sort Set', 'sort_set_delete' => 'Delete Sort Set', 'sort_set_delete_desc' => 'Remove this sort set from the system. Deletion will only go ahead if the sort is not in active use.', + 'sort_set_delete_fail_books' => 'Unable to delete this sort set since it has books assigned.', + 'sort_set_delete_fail_default' => 'Unable to delete this sort set since it\'s used as the default book sort.', 'sort_set_details' => 'Sort Set Details', 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', 'sort_set_operations' => 'Sort Operations', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index b5d613840..0af3a8fb8 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,5 +1,9 @@ @extends('settings.layout') +@php + $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); +@endphp + @section('card')

    {{ trans('settings.sorting') }}

    @@ -19,15 +23,13 @@ -{{-- TODO--}} -{{-- @foreach(\BookStack\Users\Models\Role::all() as $role)--}} -{{-- --}} -{{-- @endforeach--}} + @foreach($sortSets as $set) + + @endforeach
    @@ -52,9 +54,6 @@ - @php - $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); - @endphp @if(empty($sortSets))

    {{ trans('common.no_items') }}

    @else From c13ce1883708c184d14b3be10734894ddf7c9e00 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Feb 2025 16:52:20 +0000 Subject: [PATCH 28/56] Sorting: Added book autosort logic --- app/Entities/Repos/BaseRepo.php | 15 +++++ app/Entities/Repos/ChapterRepo.php | 6 ++ app/Entities/Repos/PageRepo.php | 6 ++ app/Sorting/BookSorter.php | 48 ++++++++++++++ app/Sorting/SortSetOperation.php | 9 +++ app/Sorting/SortSetOperationComparisons.php | 69 +++++++++++++++++++++ 6 files changed, 153 insertions(+) create mode 100644 app/Sorting/SortSetOperationComparisons.php diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 033350743..151d5b055 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -4,6 +4,7 @@ namespace BookStack\Entities\Repos; use BookStack\Activity\TagRepo; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\HasCoverImage; @@ -12,6 +13,7 @@ use BookStack\Entities\Queries\PageQueries; use BookStack\Exceptions\ImageUploadException; use BookStack\References\ReferenceStore; use BookStack\References\ReferenceUpdater; +use BookStack\Sorting\BookSorter; use BookStack\Uploads\ImageRepo; use BookStack\Util\HtmlDescriptionFilter; use Illuminate\Http\UploadedFile; @@ -24,6 +26,7 @@ class BaseRepo protected ReferenceUpdater $referenceUpdater, protected ReferenceStore $referenceStore, protected PageQueries $pageQueries, + protected BookSorter $bookSorter, ) { } @@ -134,6 +137,18 @@ class BaseRepo $entity->save(); } + /** + * Sort the parent of the given entity, if any auto sort actions are set for it. + * Typical ran during create/update/insert events. + */ + public function sortParent(Entity $entity): void + { + if ($entity instanceof BookChild) { + $book = $entity->book; + $this->bookSorter->runBookAutoSort($book); + } + } + protected function updateDescription(Entity $entity, array $input): void { if (!in_array(HasHtmlDescription::class, class_uses($entity))) { diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index 17cbccd41..fdf2de4e2 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -34,6 +34,8 @@ class ChapterRepo $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null)); Activity::add(ActivityType::CHAPTER_CREATE, $chapter); + $this->baseRepo->sortParent($chapter); + return $chapter; } @@ -50,6 +52,8 @@ class ChapterRepo Activity::add(ActivityType::CHAPTER_UPDATE, $chapter); + $this->baseRepo->sortParent($chapter); + return $chapter; } @@ -88,6 +92,8 @@ class ChapterRepo $chapter->rebuildPermissions(); Activity::add(ActivityType::CHAPTER_MOVE, $chapter); + $this->baseRepo->sortParent($chapter); + return $parent; } } diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 68b1c398f..c3be6d826 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -83,6 +83,7 @@ class PageRepo $draft->refresh(); Activity::add(ActivityType::PAGE_CREATE, $draft); + $this->baseRepo->sortParent($draft); return $draft; } @@ -128,6 +129,7 @@ class PageRepo } Activity::add(ActivityType::PAGE_UPDATE, $page); + $this->baseRepo->sortParent($page); return $page; } @@ -243,6 +245,8 @@ class PageRepo Activity::add(ActivityType::PAGE_RESTORE, $page); Activity::add(ActivityType::REVISION_RESTORE, $revision); + $this->baseRepo->sortParent($page); + return $page; } @@ -272,6 +276,8 @@ class PageRepo Activity::add(ActivityType::PAGE_MOVE, $page); + $this->baseRepo->sortParent($page); + return $parent; } diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index 7268b3543..e89fdaccc 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -16,6 +16,54 @@ class BookSorter ) { } + /** + * Runs the auto-sort for a book if the book has a sort set applied to it. + * This does not consider permissions since the sort operations are centrally + * managed by admins so considered permitted if existing and assigned. + */ + public function runBookAutoSort(Book $book): void + { + $set = $book->sortSet; + if (!$set) { + return; + } + + $sortFunctions = array_map(function (SortSetOperation $op) { + return $op->getSortFunction(); + }, $set->getOperations()); + + $chapters = $book->chapters() + ->with('pages:id,name,priority,created_at,updated_at') + ->get(['id', 'name', 'priority', 'created_at', 'updated_at']); + + /** @var (Chapter|Book)[] $topItems */ + $topItems = [ + ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']), + ...$chapters, + ]; + + foreach ($sortFunctions as $sortFunction) { + usort($topItems, $sortFunction); + } + + foreach ($topItems as $index => $topItem) { + $topItem->priority = $index + 1; + $topItem->save(); + } + + foreach ($chapters as $chapter) { + $pages = $chapter->pages->all(); + foreach ($sortFunctions as $sortFunction) { + usort($pages, $sortFunction); + } + + foreach ($pages as $index => $page) { + $page->priority = $index + 1; + $page->save(); + } + } + } + /** * Sort the books content using the given sort map. diff --git a/app/Sorting/SortSetOperation.php b/app/Sorting/SortSetOperation.php index a6dd860f5..7fdd0b002 100644 --- a/app/Sorting/SortSetOperation.php +++ b/app/Sorting/SortSetOperation.php @@ -2,6 +2,9 @@ namespace BookStack\Sorting; +use Closure; +use Illuminate\Support\Str; + enum SortSetOperation: string { case NameAsc = 'name_asc'; @@ -33,6 +36,12 @@ enum SortSetOperation: string return trim($label); } + public function getSortFunction(): callable + { + $camelValue = Str::camel($this->value); + return SortSetOperationComparisons::$camelValue(...); + } + /** * @return SortSetOperation[] */ diff --git a/app/Sorting/SortSetOperationComparisons.php b/app/Sorting/SortSetOperationComparisons.php new file mode 100644 index 000000000..e1c3e625f --- /dev/null +++ b/app/Sorting/SortSetOperationComparisons.php @@ -0,0 +1,69 @@ +name <=> $b->name; + } + + public static function nameDesc(Entity $a, Entity $b): int + { + return $b->name <=> $a->name; + } + + public static function nameNumericAsc(Entity $a, Entity $b): int + { + $numRegex = '/^\d+(\.\d+)?/'; + $aMatches = []; + $bMatches = []; + preg_match($numRegex, $a, $aMatches); + preg_match($numRegex, $b, $bMatches); + return ($aMatches[0] ?? 0) <=> ($bMatches[0] ?? 0); + } + + public static function nameNumericDesc(Entity $a, Entity $b): int + { + return -(static::nameNumericAsc($a, $b)); + } + + public static function createdDateAsc(Entity $a, Entity $b): int + { + return $a->created_at->unix() <=> $b->created_at->unix(); + } + + public static function createdDateDesc(Entity $a, Entity $b): int + { + return $b->created_at->unix() <=> $a->created_at->unix(); + } + + public static function updatedDateAsc(Entity $a, Entity $b): int + { + return $a->updated_at->unix() <=> $b->updated_at->unix(); + } + + public static function updatedDateDesc(Entity $a, Entity $b): int + { + return $b->updated_at->unix() <=> $a->updated_at->unix(); + } + + public static function chaptersFirst(Entity $a, Entity $b): int + { + return ($b instanceof Chapter ? 1 : 0) - (($a instanceof Chapter) ? 1 : 0); + } + + public static function chaptersLast(Entity $a, Entity $b): int + { + return ($a instanceof Chapter ? 1 : 0) - (($b instanceof Chapter) ? 1 : 0); + } +} From 103a8a8e8ee1528a1ce248f01bf3fd4ab16840f6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Feb 2025 21:17:48 +0000 Subject: [PATCH 29/56] Meta: Updated sponsor list, licence year and readme --- LICENSE | 2 +- readme.md | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/LICENSE b/LICENSE index 5ed2edf85..9b94d9be3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015-2024, Dan Brown and the BookStack Project contributors. +Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/readme.md b/readme.md index 857e2051a..339c7cbd4 100644 --- a/readme.md +++ b/readme.md @@ -29,11 +29,11 @@ A platform for storing and organising information and documentation. Details for ## 📚 Project Definition -BookStack is an opinionated documentation platform that provides a pleasant and simple out-of-the-box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it but they should not interfere with the core simple user experience. +BookStack is an opinionated documentation platform that provides a pleasant and simple out-of-the-box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it, but they should not interfere with the core simple user experience. BookStack is not designed as an extensible platform to be used for purposes that differ to the statement above. -In regard to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time. +In regard to development philosophy, BookStack has a relaxed, open & positive approach. We aim to slowly yet continuously evolve the platform while providing a stable & easy upgrade path. You can read more about the project and its origins in [our FAQ here](https://www.bookstackapp.com/about/project-faq/). @@ -82,15 +82,11 @@ Big thanks to these companies for supporting the project. Practinet - - Transport Talent - - - - + Route4Me - Route Optimizer and Route Planner Software - + + ## 🛠️ Development & Testing @@ -134,7 +130,7 @@ We want BookStack to remain accessible to as many people as possible. We aim for ## 🖥️ Website, Docs & Blog -The website which contains the project docs & blog can be found in the [BookStackApp/website](https://github.com/BookStackApp/website) repo. +The website which contains the project docs & blog can be found in the [BookStackApp/website](https://codeberg.org/bookstack/website) repo. ## ⚖️ License @@ -161,7 +157,7 @@ Note: This is not an exhaustive list of all libraries and projects that would be * [KnpLabs/snappy](https://github.com/KnpLabs/snappy) - _[MIT](https://github.com/KnpLabs/snappy/blob/master/LICENSE)_ * [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html) - _[LGPL v3.0](https://github.com/wkhtmltopdf/wkhtmltopdf/blob/master/LICENSE)_ * [diagrams.net](https://github.com/jgraph/drawio) - _[Embedded Version Terms](https://www.diagrams.net/trust/) / [Source Project - Apache-2.0](https://github.com/jgraph/drawio/blob/dev/LICENSE)_ -* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml) - _[MIT](https://github.com/onelogin/php-saml/blob/master/LICENSE)_ +* [SAML PHP Toolkit](https://github.com/SAML-Toolkits/php-saml) - _[MIT](https://github.com/SAML-Toolkits/php-saml/blob/master/LICENSE)_ * [League/CommonMark](https://commonmark.thephpleague.com/) - _[BSD-3-Clause](https://github.com/thephpleague/commonmark/blob/2.2/LICENSE)_ * [League/Flysystem](https://flysystem.thephpleague.com) - _[MIT](https://github.com/thephpleague/flysystem/blob/3.x/LICENSE)_ * [League/html-to-markdown](https://github.com/thephpleague/html-to-markdown) - _[MIT](https://github.com/thephpleague/html-to-markdown/blob/master/LICENSE)_ From ccd94684ebc5d8b14a9fa4e7b0808501b3c80cd9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 6 Feb 2025 14:58:08 +0000 Subject: [PATCH 30/56] Sorting: Improved sort set display, delete, added action on edit - Changes to a sort set will now auto-apply to assinged books (basic chunck through all on save). - Added book count indicator to sort set list items. - Deletion now has confirmation and auto-handling of assigned books/settings. --- app/Sorting/BookSorter.php | 9 ++++++ app/Sorting/SortSetController.php | 32 +++++++++++++++---- lang/en/settings.php | 6 ++-- .../settings/categories/sorting.blade.php | 5 ++- .../views/settings/sort-sets/edit.blade.php | 16 ++++++++-- .../parts/sort-set-list-item.blade.php | 10 ++++-- 6 files changed, 61 insertions(+), 17 deletions(-) diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index e89fdaccc..fd99a8d37 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -16,6 +16,15 @@ class BookSorter ) { } + public function runBookAutoSortForAllWithSet(SortSet $set): void + { + $set->books()->chunk(50, function ($books) { + foreach ($books as $book) { + $this->runBookAutoSort($book); + } + }); + } + /** * Runs the auto-sort for a book if the book has a sort set applied to it. * This does not consider permissions since the sort operations are centrally diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index 8f5120791..b0ad2a7d7 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -52,7 +52,7 @@ class SortSetController extends Controller return view('settings.sort-sets.edit', ['set' => $set]); } - public function update(string $id, Request $request) + public function update(string $id, Request $request, BookSorter $bookSorter) { $this->validate($request, [ 'name' => ['required', 'string', 'min:1', 'max:200'], @@ -67,26 +67,44 @@ class SortSetController extends Controller $set->name = $request->input('name'); $set->setOperations($operations); + $changedSequence = $set->isDirty('sequence'); $set->save(); $this->logActivity(ActivityType::SORT_SET_UPDATE, $set); + if ($changedSequence) { + $bookSorter->runBookAutoSortForAllWithSet($set); + } + return redirect('/settings/sorting'); } - public function destroy(string $id) + public function destroy(string $id, Request $request) { $set = SortSet::query()->findOrFail($id); + $confirmed = $request->input('confirm') === 'true'; + $booksAssigned = $set->books()->count(); + $warnings = []; - if ($set->books()->count() > 0) { - $this->showErrorNotification(trans('settings.sort_set_delete_fail_books')); - return redirect($set->getUrl()); + if ($booksAssigned > 0) { + if ($confirmed) { + $set->books()->update(['sort_set_id' => null]); + } else { + $warnings[] = trans('settings.sort_set_delete_warn_books', ['count' => $booksAssigned]); + } } $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); if ($defaultBookSortSetting === intval($id)) { - $this->showErrorNotification(trans('settings.sort_set_delete_fail_default')); - return redirect($set->getUrl()); + if ($confirmed) { + setting()->remove('sorting-book-default'); + } else { + $warnings[] = trans('settings.sort_set_delete_warn_default'); + } + } + + if (count($warnings) > 0) { + return redirect($set->getUrl() . '#delete')->withErrors(['delete' => $warnings]); } $set->delete(); diff --git a/lang/en/settings.php b/lang/en/settings.php index eb046d278..19ffd9240 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -83,9 +83,9 @@ return [ 'sort_set_create' => 'Create Sort Set', 'sort_set_edit' => 'Edit Sort Set', 'sort_set_delete' => 'Delete Sort Set', - 'sort_set_delete_desc' => 'Remove this sort set from the system. Deletion will only go ahead if the sort is not in active use.', - 'sort_set_delete_fail_books' => 'Unable to delete this sort set since it has books assigned.', - 'sort_set_delete_fail_default' => 'Unable to delete this sort set since it\'s used as the default book sort.', + 'sort_set_delete_desc' => 'Remove this sort set from the system. Books using this sort will revert to manual sorting.', + 'sort_set_delete_warn_books' => 'This sort set is currently used on :count book(s). Are you sure you want to delete this?', + 'sort_set_delete_warn_default' => 'This sort set is currently used as the default for books. Are you sure you want to delete this?', 'sort_set_details' => 'Sort Set Details', 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', 'sort_set_operations' => 'Sort Operations', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 0af3a8fb8..60fb329b6 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,7 +1,10 @@ @extends('settings.layout') @php - $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); + $sortSets = \BookStack\Sorting\SortSet::query() + ->withCount('books') + ->orderBy('name', 'asc') + ->get(); @endphp @section('card') diff --git a/resources/views/settings/sort-sets/edit.blade.php b/resources/views/settings/sort-sets/edit.blade.php index 3b88c1243..febcd9ffe 100644 --- a/resources/views/settings/sort-sets/edit.blade.php +++ b/resources/views/settings/sort-sets/edit.blade.php @@ -22,16 +22,26 @@ -
    +
    -
    +

    {{ trans('settings.sort_set_delete') }}

    -

    {{ trans('settings.sort_set_delete_desc') }}

    +

    {{ trans('settings.sort_set_delete_desc') }}

    + @if($errors->has('delete')) + @foreach($errors->get('delete') as $error) +

    {{ $error }}

    + @endforeach + @endif
    {{ method_field('DELETE') }} {{ csrf_field() }} + + @if($errors->has('delete')) + + @endif +
    diff --git a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php index e5ee1fb87..e977c286e 100644 --- a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php +++ b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php @@ -1,8 +1,12 @@ -
    -
    +
    + -
    +
    {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $set->getOperations())) }}
    +
    + @icon('book'){{ $set->books_count ?? 0 }} +
    \ No newline at end of file From d938565839e5c59576b45ccb6e8caa40baa7129d Mon Sep 17 00:00:00 2001 From: inv-hareesh Date: Fri, 7 Feb 2025 08:59:36 +0530 Subject: [PATCH 31/56] =?UTF-8?q?Fix=20search=20issue=20for=20words=20insi?= =?UTF-8?q?de=20Guillemets=20(=C2=AB=20=C2=BB)=20without=20spaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Search/SearchIndex.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Search/SearchIndex.php b/app/Search/SearchIndex.php index c7d9d6502..e10219e2d 100644 --- a/app/Search/SearchIndex.php +++ b/app/Search/SearchIndex.php @@ -16,7 +16,7 @@ class SearchIndex /** * A list of delimiter characters used to break-up parsed content into terms for indexing. */ - public static string $delimiters = " \n\t.,!?:;()[]{}<>`'\""; + public static string $delimiters = " \n\t.,!?:;()[]{}<>`'\"«»"; public function __construct( protected EntityProvider $entityProvider From ec7951749333fb9b57c3e5ce368f3427065489b3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 9 Feb 2025 15:16:18 +0000 Subject: [PATCH 32/56] Sorting: Added auto sort option to book sort UI Includes indicator on books added to sort operation. --- app/Sorting/BookSortController.php | 36 +++++++++++++------ app/Sorting/SortSet.php | 9 +++++ lang/en/entities.php | 4 ++- resources/icons/auto-sort.svg | 1 + resources/sass/_lists.scss | 4 +++ .../views/books/parts/sort-box.blade.php | 5 +++ resources/views/books/sort.blade.php | 28 +++++++++++++-- .../settings/categories/sorting.blade.php | 5 +-- 8 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 resources/icons/auto-sort.svg diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php index feed5db4f..98d79d0fd 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -44,24 +44,40 @@ class BookSortController extends Controller } /** - * Sorts a book using a given mapping array. + * Update the sort options of a book, setting the auto-sort and/or updating + * child order via mapping. */ public function update(Request $request, BookSorter $sorter, string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission('book-update', $book); + $loggedActivityForBook = false; - // Return if no map sent - if (!$request->filled('sort-tree')) { - return redirect($book->getUrl()); + // Sort via map + if ($request->filled('sort-tree')) { + $sortMap = BookSortMap::fromJson($request->get('sort-tree')); + $booksInvolved = $sorter->sortUsingMap($sortMap); + + // Rebuild permissions and add activity for involved books. + foreach ($booksInvolved as $bookInvolved) { + Activity::add(ActivityType::BOOK_SORT, $bookInvolved); + if ($bookInvolved->id === $book->id) { + $loggedActivityForBook = true; + } + } } - $sortMap = BookSortMap::fromJson($request->get('sort-tree')); - $booksInvolved = $sorter->sortUsingMap($sortMap); - - // Rebuild permissions and add activity for involved books. - foreach ($booksInvolved as $bookInvolved) { - Activity::add(ActivityType::BOOK_SORT, $bookInvolved); + if ($request->filled('auto-sort')) { + $sortSetId = intval($request->get('auto-sort')) ?: null; + if ($sortSetId && SortSet::query()->find($sortSetId) === null) { + $sortSetId = null; + } + $book->sort_set_id = $sortSetId; + $book->save(); + $sorter->runBookAutoSort($book); + if (!$loggedActivityForBook) { + Activity::add(ActivityType::BOOK_SORT, $book); + } } return redirect($book->getUrl()); diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index a73407bfa..8cdee1df4 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -5,6 +5,7 @@ namespace BookStack\Sorting; use BookStack\Activity\Models\Loggable; use BookStack\Entities\Models\Book; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -48,4 +49,12 @@ class SortSet extends Model implements Loggable { return $this->hasMany(Book::class); } + + public static function allByName(): Collection + { + return static::query() + ->withCount('books') + ->orderBy('name', 'asc') + ->get(); + } } diff --git a/lang/en/entities.php b/lang/en/entities.php index 26a563a7e..28a209fa2 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -166,7 +166,9 @@ return [ 'books_search_this' => 'Search this book', 'books_navigation' => 'Book Navigation', 'books_sort' => 'Sort Book Contents', - 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort option can be set to automatically sort this book\'s contents upon changes.', + 'books_sort_auto_sort' => 'Auto Sort Option', + 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', diff --git a/resources/icons/auto-sort.svg b/resources/icons/auto-sort.svg new file mode 100644 index 000000000..c3cb2f516 --- /dev/null +++ b/resources/icons/auto-sort.svg @@ -0,0 +1 @@ + diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index fd76f498e..1e503dd0f 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -242,6 +242,10 @@ margin-bottom: vars.$m; padding: vars.$m vars.$xl; position: relative; + summary:focus { + outline: 1px dashed var(--color-primary); + outline-offset: 5px; + } &::before { pointer-events: none; content: ''; diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index 03998e261..232616168 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -8,6 +8,11 @@ @icon('book') {{ $book->name }}
    +
    + @if($book->sortSet) + @icon('auto-sort') + @endif +
    diff --git a/resources/views/books/sort.blade.php b/resources/views/books/sort.blade.php index c82ad4e3b..3c59ac1e0 100644 --- a/resources/views/books/sort.blade.php +++ b/resources/views/books/sort.blade.php @@ -18,14 +18,36 @@

    {{ trans('entities.books_sort') }}

    -

    {{ trans('entities.books_sort_desc') }}

    + +
    +

    {{ trans('entities.books_sort_desc') }}

    +
    + @php + $autoSortVal = intval(old('auto-sort') ?? $book->sort_set_id ?? 0); + @endphp + + +
    +
    @include('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren])
    - - {!! csrf_field() !!} + + {{ csrf_field() }}
    diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 60fb329b6..6a52873e6 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,10 +1,7 @@ @extends('settings.layout') @php - $sortSets = \BookStack\Sorting\SortSet::query() - ->withCount('books') - ->orderBy('name', 'asc') - ->get(); + $sortSets = \BookStack\Sorting\SortSet::allByName(); @endphp @section('card') From 37d020c08350721320d3e7e3c8533daec544bb03 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 9 Feb 2025 17:44:24 +0000 Subject: [PATCH 33/56] Sorting: Addded command to apply sort sets --- app/Console/Commands/AssignSortSetCommand.php | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 app/Console/Commands/AssignSortSetCommand.php diff --git a/app/Console/Commands/AssignSortSetCommand.php b/app/Console/Commands/AssignSortSetCommand.php new file mode 100644 index 000000000..aca046bae --- /dev/null +++ b/app/Console/Commands/AssignSortSetCommand.php @@ -0,0 +1,99 @@ +argument('sort-set')) ?? 0; + if ($sortSetId === 0) { + return $this->listSortSets(); + } + + $set = SortSet::query()->find($sortSetId); + if ($this->option('all-books')) { + $query = Book::query(); + } else if ($this->option('books-without-sort')) { + $query = Book::query()->whereNull('sort_set_id'); + } else if ($this->option('books-with-sort')) { + $sortId = intval($this->option('books-with-sort')) ?: 0; + if (!$sortId) { + $this->error("Provided --books-with-sort option value is invalid"); + return 1; + } + $query = Book::query()->where('sort_set_id', $sortId); + } else { + $this->error("Either the --all-books or --books-without-sort option must be provided!"); + return 1; + } + + if (!$set) { + $this->error("Sort set of provided id {$sortSetId} not found!"); + return 1; + } + + $count = $query->clone()->count(); + $this->warn("This will apply sort set [{$set->id}: {$set->name}] to {$count} book(s) and run the sort on each."); + $confirmed = $this->confirm("Are you sure you want to continue?"); + + if (!$confirmed) { + return 1; + } + + $processed = 0; + $query->chunkById(10, function ($books) use ($set, $sorter, $count, &$processed) { + $max = min($count, ($processed + 10)); + $this->info("Applying to {$processed}-{$max} of {$count} books"); + foreach ($books as $book) { + $book->sort_set_id = $set->id; + $book->save(); + $sorter->runBookAutoSort($book); + } + $processed = $max; + }); + + $this->info("Sort applied to {$processed} books!"); + + return 0; + } + + protected function listSortSets(): int + { + + $sets = SortSet::query()->orderBy('id', 'asc')->get(); + $this->error("Sort set ID required!"); + $this->warn("\nAvailable sort sets:"); + foreach ($sets as $set) { + $this->info("{$set->id}: {$set->name}"); + } + + return 1; + } +} From 69683d50ecb854211fc9bb4502c9ef512ad23d8e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 9 Feb 2025 23:24:36 +0000 Subject: [PATCH 34/56] Sorting: Added tests to cover AssignSortSetCommand --- app/Console/Commands/AssignSortSetCommand.php | 4 +- app/Sorting/SortSet.php | 3 + database/factories/Sorting/SortSetFactory.php | 30 +++++ tests/Commands/AssignSortSetCommandTest.php | 112 ++++++++++++++++++ 4 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 database/factories/Sorting/SortSetFactory.php create mode 100644 tests/Commands/AssignSortSetCommandTest.php diff --git a/app/Console/Commands/AssignSortSetCommand.php b/app/Console/Commands/AssignSortSetCommand.php index aca046bae..484f69952 100644 --- a/app/Console/Commands/AssignSortSetCommand.php +++ b/app/Console/Commands/AssignSortSetCommand.php @@ -50,7 +50,7 @@ class AssignSortSetCommand extends Command } $query = Book::query()->where('sort_set_id', $sortId); } else { - $this->error("Either the --all-books or --books-without-sort option must be provided!"); + $this->error("No option provided to specify target. Run with the -h option to see all available options."); return 1; } @@ -79,7 +79,7 @@ class AssignSortSetCommand extends Command $processed = $max; }); - $this->info("Sort applied to {$processed} books!"); + $this->info("Sort applied to {$processed} book(s)!"); return 0; } diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index 8cdee1df4..cc8879f96 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable; use BookStack\Entities\Models\Book; use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -18,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; */ class SortSet extends Model implements Loggable { + use HasFactory; + /** * @return SortSetOperation[] */ diff --git a/database/factories/Sorting/SortSetFactory.php b/database/factories/Sorting/SortSetFactory.php new file mode 100644 index 000000000..36e0a6976 --- /dev/null +++ b/database/factories/Sorting/SortSetFactory.php @@ -0,0 +1,30 @@ + $op->name . ' Sort', + 'sequence' => $op->value, + ]; + } +} diff --git a/tests/Commands/AssignSortSetCommandTest.php b/tests/Commands/AssignSortSetCommandTest.php new file mode 100644 index 000000000..01cb5caa5 --- /dev/null +++ b/tests/Commands/AssignSortSetCommandTest.php @@ -0,0 +1,112 @@ +createMany(10); + + $commandRun = $this->artisan('bookstack:assign-sort-set') + ->expectsOutputToContain('Sort set ID required!') + ->assertExitCode(1); + + foreach ($sortSets as $sortSet) { + $commandRun->expectsOutputToContain("{$sortSet->id}: {$sortSet->name}"); + } + } + + public function test_run_without_options_advises_help() + { + $this->artisan("bookstack:assign-sort-set 100") + ->expectsOutput("No option provided to specify target. Run with the -h option to see all available options.") + ->assertExitCode(1); + } + + public function test_run_without_valid_sort_advises_help() + { + $this->artisan("bookstack:assign-sort-set 100342 --all-books") + ->expectsOutput("Sort set of provided id 100342 not found!") + ->assertExitCode(1); + } + + public function test_confirmation_required() + { + $sortSet = SortSet::factory()->create(); + + $this->artisan("bookstack:assign-sort-set {$sortSet->id} --all-books") + ->expectsConfirmation('Are you sure you want to continue?', 'no') + ->assertExitCode(1); + + $booksWithSort = Book::query()->whereNotNull('sort_set_id')->count(); + $this->assertEquals(0, $booksWithSort); + } + + public function test_assign_to_all_books() + { + $sortSet = SortSet::factory()->create(); + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertGreaterThan(0, $booksWithoutSort); + + $this->artisan("bookstack:assign-sort-set {$sortSet->id} --all-books") + ->expectsOutputToContain("This will apply sort set [{$sortSet->id}: {$sortSet->name}] to {$booksWithoutSort} book(s)") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)") + ->assertExitCode(0); + + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertEquals(0, $booksWithoutSort); + } + + public function test_assign_to_all_books_without_sort() + { + $totalBooks = Book::query()->count(); + $book = $this->entities->book(); + $sortSetA = SortSet::factory()->create(); + $sortSetB = SortSet::factory()->create(); + $book->sort_set_id = $sortSetA->id; + $book->save(); + + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertEquals($totalBooks, $booksWithoutSort + 1); + + $this->artisan("bookstack:assign-sort-set {$sortSetB->id} --books-without-sort") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)") + ->assertExitCode(0); + + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertEquals(0, $booksWithoutSort); + $this->assertEquals($totalBooks, $sortSetB->books()->count() + 1); + } + + public function test_assign_to_all_books_with_sort() + { + $book = $this->entities->book(); + $sortSetA = SortSet::factory()->create(); + $sortSetB = SortSet::factory()->create(); + $book->sort_set_id = $sortSetA->id; + $book->save(); + + $this->artisan("bookstack:assign-sort-set {$sortSetB->id} --books-with-sort={$sortSetA->id}") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to 1 book(s)") + ->assertExitCode(0); + + $book->refresh(); + $this->assertEquals($sortSetB->id, $book->sort_set_id); + $this->assertEquals(1, $sortSetB->books()->count()); + } + + public function test_assign_to_all_books_with_sort_id_is_validated() + { + $this->artisan("bookstack:assign-sort-set 50 --books-with-sort=beans") + ->expectsOutputToContain("Provided --books-with-sort option value is invalid") + ->assertExitCode(1); + } +} From a65701294eaad4aad5ba5d9e249cbfb774ee6ca6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 10 Feb 2025 13:33:10 +0000 Subject: [PATCH 35/56] Sorting: Split out test class, added book autosort tests Just for test view, actual functionality of autosort on change still needs to be tested. --- app/Sorting/BookSorter.php | 2 +- .../SortTest.php => Sorting/BookSortTest.php} | 293 ++++-------------- tests/Sorting/MoveTest.php | 221 +++++++++++++ 3 files changed, 287 insertions(+), 229 deletions(-) rename tests/{Entity/SortTest.php => Sorting/BookSortTest.php} (51%) create mode 100644 tests/Sorting/MoveTest.php diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index fd99a8d37..b6fe33b9c 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -42,7 +42,7 @@ class BookSorter }, $set->getOperations()); $chapters = $book->chapters() - ->with('pages:id,name,priority,created_at,updated_at') + ->with('pages:id,name,priority,created_at,updated_at,chapter_id') ->get(['id', 'name', 'priority', 'created_at', 'updated_at']); /** @var (Chapter|Book)[] $topItems */ diff --git a/tests/Entity/SortTest.php b/tests/Sorting/BookSortTest.php similarity index 51% rename from tests/Entity/SortTest.php rename to tests/Sorting/BookSortTest.php index 9a5a2fe17..a726da148 100644 --- a/tests/Entity/SortTest.php +++ b/tests/Sorting/BookSortTest.php @@ -1,239 +1,15 @@ asAdmin(); - $pageRepo = app(PageRepo::class); - $book = $this->entities->book(); - $draft = $pageRepo->getNewDraftPage($book); - - $resp = $this->get($book->getUrl()); - $resp->assertSee($draft->name); - - $resp = $this->get($book->getUrl() . '/sort'); - $resp->assertDontSee($draft->name); - } - - public function test_page_move_into_book() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - - $resp = $this->asEditor()->get($page->getUrl('/move')); - $resp->assertSee('Move Page'); - - $movePageResp = $this->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $page->refresh(); - - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); - - $newBookResp = $this->get($newBook->getUrl()); - $newBookResp->assertSee('moved page'); - $newBookResp->assertSee($page->name); - } - - public function test_page_move_into_chapter() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $newChapter = $newBook->chapters()->first(); - - $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ - 'entity_selection' => 'chapter:' . $newChapter->id, - ]); - $page->refresh(); - - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new chapter'); - - $newChapterResp = $this->get($newChapter->getUrl()); - $newChapterResp->assertSee($page->name); - } - - public function test_page_move_from_chapter_to_book() - { - $oldChapter = Chapter::query()->first(); - $page = $oldChapter->pages()->first(); - $newBook = Book::query()->where('id', '!=', $oldChapter->book_id)->first(); - - $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $page->refresh(); - - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book'); - $this->assertTrue($page->chapter === null, 'Page has no parent chapter'); - - $newBookResp = $this->get($newBook->getUrl()); - $newBookResp->assertSee($page->name); - } - - public function test_page_move_requires_create_permissions_on_parent() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], $editor->roles->all()); - - $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($movePageResp); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all()); - $movePageResp = $this->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $page->refresh(); - $movePageResp->assertRedirect($page->getUrl()); - - $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_page_move_requires_delete_permissions() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $this->permissions->setEntityPermissions($page, ['view', 'update', 'create'], $editor->roles->all()); - - $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($movePageResp); - $pageView = $this->get($page->getUrl()); - $pageView->assertDontSee($page->getUrl('/move')); - - $this->permissions->setEntityPermissions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $movePageResp = $this->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $page->refresh(); - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_chapter_move() - { - $chapter = $this->entities->chapter(); - $currentBook = $chapter->book; - $pageToCheck = $chapter->pages->first(); - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - - $chapterMoveResp = $this->asEditor()->get($chapter->getUrl('/move')); - $chapterMoveResp->assertSee('Move Chapter'); - - $moveChapterResp = $this->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $chapter = Chapter::query()->find($chapter->id); - $moveChapterResp->assertRedirect($chapter->getUrl()); - $this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book'); - - $newBookResp = $this->get($newBook->getUrl()); - $newBookResp->assertSee('moved chapter'); - $newBookResp->assertSee($chapter->name); - - $pageToCheck = Page::query()->find($pageToCheck->id); - $this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book'); - $pageCheckResp = $this->get($pageToCheck->getUrl()); - $pageCheckResp->assertSee($newBook->name); - } - - public function test_chapter_move_requires_delete_permissions() - { - $chapter = $this->entities->chapter(); - $currentBook = $chapter->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create'], $editor->roles->all()); - - $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($moveChapterResp); - $pageView = $this->get($chapter->getUrl()); - $pageView->assertDontSee($chapter->getUrl('/move')); - - $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $moveChapterResp = $this->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $chapter = Chapter::query()->find($chapter->id); - $moveChapterResp->assertRedirect($chapter->getUrl()); - $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_chapter_move_requires_create_permissions_in_new_book() - { - $chapter = $this->entities->chapter(); - $currentBook = $chapter->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]); - $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); - - $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($moveChapterResp); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); - $moveChapterResp = $this->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $chapter = Chapter::query()->find($chapter->id); - $moveChapterResp->assertRedirect($chapter->getUrl()); - $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_chapter_move_changes_book_for_deleted_pages_within() - { - /** @var Chapter $chapter */ - $chapter = Chapter::query()->whereHas('pages')->first(); - $currentBook = $chapter->book; - $pageToCheck = $chapter->pages->first(); - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - - $pageToCheck->delete(); - - $this->asEditor()->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $pageToCheck->refresh(); - $this->assertEquals($newBook->id, $pageToCheck->book_id); - } - public function test_book_sort_page_shows() { $bookToSort = $this->entities->book(); @@ -246,6 +22,20 @@ class SortTest extends TestCase $resp->assertSee($bookToSort->name); } + public function test_drafts_do_not_show_up() + { + $this->asAdmin(); + $pageRepo = app(PageRepo::class); + $book = $this->entities->book(); + $draft = $pageRepo->getNewDraftPage($book); + + $resp = $this->get($book->getUrl()); + $resp->assertSee($draft->name); + + $resp = $this->get($book->getUrl('/sort')); + $resp->assertDontSee($draft->name); + } + public function test_book_sort() { $oldBook = $this->entities->book(); @@ -423,7 +213,7 @@ class SortTest extends TestCase $firstPage = $bookToSort->pages[0]; $firstChapter = $bookToSort->chapters[0]; - $resp = $this->asAdmin()->get($bookToSort->getUrl() . '/sort-item'); + $resp = $this->asAdmin()->get($bookToSort->getUrl('/sort-item')); // Ensure book details are returned $resp->assertSee($bookToSort->name); @@ -431,6 +221,53 @@ class SortTest extends TestCase $resp->assertSee($firstChapter->name); } + public function test_book_sort_item_shows_auto_sort_status() + { + $sort = SortSet::factory()->create(['name' => 'My sort']); + $book = $this->entities->book(); + + $resp = $this->asAdmin()->get($book->getUrl('/sort-item')); + $this->withHtml($resp)->assertElementNotExists("span[title='Auto Sort Active: My sort']"); + + $book->sort_set_id = $sort->id; + $book->save(); + + $resp = $this->asAdmin()->get($book->getUrl('/sort-item')); + $this->withHtml($resp)->assertElementExists("span[title='Auto Sort Active: My sort']"); + } + + public function test_auto_sort_options_shown_on_sort_page() + { + $sort = SortSet::factory()->create(); + $book = $this->entities->book(); + $resp = $this->asAdmin()->get($book->getUrl('/sort')); + + $this->withHtml($resp)->assertElementExists('select[name="auto-sort"] option[value="' . $sort->id . '"]'); + } + + public function test_auto_sort_option_submit_saves_to_book() + { + $sort = SortSet::factory()->create(); + $book = $this->entities->book(); + $bookPage = $book->pages()->first(); + $bookPage->priority = 10000; + $bookPage->save(); + + $resp = $this->asAdmin()->put($book->getUrl('/sort'), [ + 'auto-sort' => $sort->id, + ]); + + $resp->assertRedirect($book->getUrl()); + $book->refresh(); + $bookPage->refresh(); + + $this->assertEquals($sort->id, $book->sort_set_id); + $this->assertNotEquals(10000, $bookPage->priority); + + $resp = $this->get($book->getUrl('/sort')); + $this->withHtml($resp)->assertElementExists('select[name="auto-sort"] option[value="' . $sort->id . '"][selected]'); + } + public function test_pages_in_book_show_sorted_by_priority() { $book = $this->entities->bookHasChaptersAndPages(); diff --git a/tests/Sorting/MoveTest.php b/tests/Sorting/MoveTest.php new file mode 100644 index 000000000..edae1f3a3 --- /dev/null +++ b/tests/Sorting/MoveTest.php @@ -0,0 +1,221 @@ +entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + + $resp = $this->asEditor()->get($page->getUrl('/move')); + $resp->assertSee('Move Page'); + + $movePageResp = $this->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $page->refresh(); + + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); + + $newBookResp = $this->get($newBook->getUrl()); + $newBookResp->assertSee('moved page'); + $newBookResp->assertSee($page->name); + } + + public function test_page_move_into_chapter() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $newChapter = $newBook->chapters()->first(); + + $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ + 'entity_selection' => 'chapter:' . $newChapter->id, + ]); + $page->refresh(); + + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new chapter'); + + $newChapterResp = $this->get($newChapter->getUrl()); + $newChapterResp->assertSee($page->name); + } + + public function test_page_move_from_chapter_to_book() + { + $oldChapter = Chapter::query()->first(); + $page = $oldChapter->pages()->first(); + $newBook = Book::query()->where('id', '!=', $oldChapter->book_id)->first(); + + $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $page->refresh(); + + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book'); + $this->assertTrue($page->chapter === null, 'Page has no parent chapter'); + + $newBookResp = $this->get($newBook->getUrl()); + $newBookResp->assertSee($page->name); + } + + public function test_page_move_requires_create_permissions_on_parent() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], $editor->roles->all()); + + $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($movePageResp); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all()); + $movePageResp = $this->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $page->refresh(); + $movePageResp->assertRedirect($page->getUrl()); + + $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_page_move_requires_delete_permissions() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($page, ['view', 'update', 'create'], $editor->roles->all()); + + $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($movePageResp); + $pageView = $this->get($page->getUrl()); + $pageView->assertDontSee($page->getUrl('/move')); + + $this->permissions->setEntityPermissions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $movePageResp = $this->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $page->refresh(); + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_chapter_move() + { + $chapter = $this->entities->chapter(); + $currentBook = $chapter->book; + $pageToCheck = $chapter->pages->first(); + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + + $chapterMoveResp = $this->asEditor()->get($chapter->getUrl('/move')); + $chapterMoveResp->assertSee('Move Chapter'); + + $moveChapterResp = $this->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $chapter = Chapter::query()->find($chapter->id); + $moveChapterResp->assertRedirect($chapter->getUrl()); + $this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book'); + + $newBookResp = $this->get($newBook->getUrl()); + $newBookResp->assertSee('moved chapter'); + $newBookResp->assertSee($chapter->name); + + $pageToCheck = Page::query()->find($pageToCheck->id); + $this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book'); + $pageCheckResp = $this->get($pageToCheck->getUrl()); + $pageCheckResp->assertSee($newBook->name); + } + + public function test_chapter_move_requires_delete_permissions() + { + $chapter = $this->entities->chapter(); + $currentBook = $chapter->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create'], $editor->roles->all()); + + $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($moveChapterResp); + $pageView = $this->get($chapter->getUrl()); + $pageView->assertDontSee($chapter->getUrl('/move')); + + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $moveChapterResp = $this->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $chapter = Chapter::query()->find($chapter->id); + $moveChapterResp->assertRedirect($chapter->getUrl()); + $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_chapter_move_requires_create_permissions_in_new_book() + { + $chapter = $this->entities->chapter(); + $currentBook = $chapter->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]); + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); + + $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($moveChapterResp); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); + $moveChapterResp = $this->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $chapter = Chapter::query()->find($chapter->id); + $moveChapterResp->assertRedirect($chapter->getUrl()); + $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_chapter_move_changes_book_for_deleted_pages_within() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + $currentBook = $chapter->book; + $pageToCheck = $chapter->pages->first(); + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + + $pageToCheck->delete(); + + $this->asEditor()->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $pageToCheck->refresh(); + $this->assertEquals($newBook->id, $pageToCheck->book_id); + } +} From a208c46b628505cfdd5caf5294a248560c485f50 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 10 Feb 2025 17:14:06 +0000 Subject: [PATCH 36/56] Sorting: Covered sort set management with tests --- app/Sorting/SortSetController.php | 3 +- .../factories/Entities/Models/BookFactory.php | 4 +- lang/en/settings.php | 1 + .../parts/sort-set-list-item.blade.php | 2 +- tests/Sorting/SortSetTest.php | 200 ++++++++++++++++++ 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 tests/Sorting/SortSetTest.php diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index b0ad2a7d7..7b1c0bc41 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -4,14 +4,13 @@ namespace BookStack\Sorting; use BookStack\Activity\ActivityType; use BookStack\Http\Controller; -use BookStack\Http\Request; +use Illuminate\Http\Request; class SortSetController extends Controller { public function __construct() { $this->middleware('can:settings-manage'); - // TODO - Test } public function create() diff --git a/database/factories/Entities/Models/BookFactory.php b/database/factories/Entities/Models/BookFactory.php index 9cb8e971c..29403a294 100644 --- a/database/factories/Entities/Models/BookFactory.php +++ b/database/factories/Entities/Models/BookFactory.php @@ -26,7 +26,9 @@ class BookFactory extends Factory 'name' => $this->faker->sentence(), 'slug' => Str::random(10), 'description' => $description, - 'description_html' => '

    ' . e($description) . '

    ' + 'description_html' => '

    ' . e($description) . '

    ', + 'sort_set_id' => null, + 'default_template_id' => null, ]; } } diff --git a/lang/en/settings.php b/lang/en/settings.php index 19ffd9240..344c186cb 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -80,6 +80,7 @@ return [ 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_sets' => 'Sort Sets', 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + 'sort_set_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', 'sort_set_create' => 'Create Sort Set', 'sort_set_edit' => 'Edit Sort Set', 'sort_set_delete' => 'Delete Sort Set', diff --git a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php index e977c286e..6c0b84047 100644 --- a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php +++ b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php @@ -6,7 +6,7 @@ {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $set->getOperations())) }}
    - @icon('book'){{ $set->books_count ?? 0 }}
    \ No newline at end of file diff --git a/tests/Sorting/SortSetTest.php b/tests/Sorting/SortSetTest.php new file mode 100644 index 000000000..5f30034bd --- /dev/null +++ b/tests/Sorting/SortSetTest.php @@ -0,0 +1,200 @@ +create(); + $user = $this->users->viewer(); + $this->actingAs($user); + + $actions = [ + ['GET', '/settings/sorting'], + ['POST', '/settings/sorting/sets'], + ['GET', "/settings/sorting/sets/{$set->id}"], + ['PUT', "/settings/sorting/sets/{$set->id}"], + ['DELETE', "/settings/sorting/sets/{$set->id}"], + ]; + + foreach ($actions as [$method, $path]) { + $resp = $this->call($method, $path); + $this->assertPermissionError($resp); + } + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + foreach ($actions as [$method, $path]) { + $resp = $this->call($method, $path); + $this->assertNotPermissionError($resp); + } + } + + public function test_create_flow() + { + $resp = $this->asAdmin()->get('/settings/sorting'); + $this->withHtml($resp)->assertLinkExists(url('/settings/sorting/sets/new')); + + $resp = $this->get('/settings/sorting/sets/new'); + $this->withHtml($resp)->assertElementExists('form[action$="/settings/sorting/sets"] input[name="name"]'); + $resp->assertSeeText('Name - Alphabetical (Asc)'); + + $details = ['name' => 'My new sort', 'sequence' => 'name_asc']; + $resp = $this->post('/settings/sorting/sets', $details); + $resp->assertRedirect('/settings/sorting'); + + $this->assertActivityExists(ActivityType::SORT_SET_CREATE); + $this->assertDatabaseHas('sort_sets', $details); + } + + public function test_listing_in_settings() + { + $set = SortSet::factory()->create(['name' => 'My super sort set', 'sequence' => 'name_asc']); + $books = Book::query()->limit(5)->get(); + foreach ($books as $book) { + $book->sort_set_id = $set->id; + $book->save(); + } + + $resp = $this->asAdmin()->get('/settings/sorting'); + $resp->assertSeeText('My super sort set'); + $resp->assertSeeText('Name - Alphabetical (Asc)'); + $this->withHtml($resp)->assertElementContains('.item-list-row [title="Assigned to 5 Books"]', '5'); + } + + public function test_update_flow() + { + $set = SortSet::factory()->create(['name' => 'My sort set to update', 'sequence' => 'name_asc']); + + $resp = $this->asAdmin()->get("/settings/sorting/sets/{$set->id}"); + $respHtml = $this->withHtml($resp); + $respHtml->assertElementContains('.configured-option-list', 'Name - Alphabetical (Asc)'); + $respHtml->assertElementNotContains('.available-option-list', 'Name - Alphabetical (Asc)'); + + $updateData = ['name' => 'My updated sort', 'sequence' => 'name_desc,chapters_last']; + $resp = $this->put("/settings/sorting/sets/{$set->id}", $updateData); + + $resp->assertRedirect('/settings/sorting'); + $this->assertActivityExists(ActivityType::SORT_SET_UPDATE); + $this->assertDatabaseHas('sort_sets', $updateData); + } + + public function test_update_triggers_resort_on_assigned_books() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $set = SortSet::factory()->create(['name' => 'My sort set to update', 'sequence' => 'name_asc']); + $book->sort_set_id = $set->id; + $book->save(); + $chapter->priority = 10000; + $chapter->save(); + + $resp = $this->asAdmin()->put("/settings/sorting/sets/{$set->id}", ['name' => $set->name, 'sequence' => 'chapters_last']); + $resp->assertRedirect('/settings/sorting'); + + $chapter->refresh(); + $this->assertNotEquals(10000, $chapter->priority); + } + + public function test_delete_flow() + { + $set = SortSet::factory()->create(); + + $resp = $this->asAdmin()->get("/settings/sorting/sets/{$set->id}"); + $resp->assertSeeText('Delete Sort Set'); + + $resp = $this->delete("settings/sorting/sets/{$set->id}"); + $resp->assertRedirect('/settings/sorting'); + + $this->assertActivityExists(ActivityType::SORT_SET_DELETE); + $this->assertDatabaseMissing('sort_sets', ['id' => $set->id]); + } + + public function test_delete_requires_confirmation_if_books_assigned() + { + $set = SortSet::factory()->create(); + $books = Book::query()->limit(5)->get(); + foreach ($books as $book) { + $book->sort_set_id = $set->id; + $book->save(); + } + + $resp = $this->asAdmin()->get("/settings/sorting/sets/{$set->id}"); + $resp->assertSeeText('Delete Sort Set'); + + $resp = $this->delete("settings/sorting/sets/{$set->id}"); + $resp->assertRedirect("/settings/sorting/sets/{$set->id}#delete"); + $resp = $this->followRedirects($resp); + + $resp->assertSeeText('This sort set is currently used on 5 book(s). Are you sure you want to delete this?'); + $this->assertDatabaseHas('sort_sets', ['id' => $set->id]); + + $resp = $this->delete("settings/sorting/sets/{$set->id}", ['confirm' => 'true']); + $resp->assertRedirect('/settings/sorting'); + $this->assertDatabaseMissing('sort_sets', ['id' => $set->id]); + $this->assertDatabaseMissing('books', ['sort_set_id' => $set->id]); + } + + public function test_page_create_triggers_book_sort() + { + $book = $this->entities->bookHasChaptersAndPages(); + $set = SortSet::factory()->create(['sequence' => 'name_asc,chapters_first']); + $book->sort_set_id = $set->id; + $book->save(); + + $resp = $this->actingAsApiEditor()->post("/api/pages", [ + 'book_id' => $book->id, + 'name' => '1111 page', + 'markdown' => 'Hi' + ]); + $resp->assertOk(); + + $this->assertDatabaseHas('pages', [ + 'book_id' => $book->id, + 'name' => '1111 page', + 'priority' => $book->chapters()->count() + 1, + ]); + } + + public function test_name_numeric_ordering() + { + $book = Book::factory()->create(); + $set = SortSet::factory()->create(['sequence' => 'name_numeric_asc']); + $book->sort_set_id = $set->id; + $book->save(); + $this->permissions->regenerateForEntity($book); + + $namesToAdd = [ + "1 - Pizza", + "2.0 - Tomato", + "2.5 - Beans", + "10 - Bread", + "20 - Milk", + ]; + + foreach ($namesToAdd as $name) { + $this->actingAsApiEditor()->post("/api/pages", [ + 'book_id' => $book->id, + 'name' => $name, + 'markdown' => 'Hello' + ]); + } + + foreach ($namesToAdd as $index => $name) { + $this->assertDatabaseHas('pages', [ + 'book_id' => $book->id, + 'name' => $name, + 'priority' => $index + 1, + ]); + } + } +} From b9306a9029f41f3779b755d08c907a2edafc33df Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 11 Feb 2025 14:36:25 +0000 Subject: [PATCH 37/56] Sorting: Renamed sort set to sort rule Renamed based on feedback from Tim and Script on Discord. Also fixed flaky test --- app/Activity/ActivityType.php | 6 +- app/Console/Commands/AssignSortSetCommand.php | 6 +- app/Entities/Models/Book.php | 10 +-- app/Entities/Repos/BookRepo.php | 6 +- app/Sorting/BookSortController.php | 4 +- app/Sorting/BookSorter.php | 6 +- app/Sorting/{SortSet.php => SortRule.php} | 12 +-- ...tController.php => SortRuleController.php} | 58 ++++++------ ...SetOperation.php => SortRuleOperation.php} | 18 ++-- .../factories/Entities/Models/BookFactory.php | 2 +- ...SortSetFactory.php => SortRuleFactory.php} | 10 +-- ..._01_29_180933_create_sort_rules_table.php} | 4 +- ...2_05_150842_add_sort_rule_id_to_books.php} | 4 +- lang/en/entities.php | 2 +- lang/en/settings.php | 52 +++++------ resources/js/components/index.ts | 2 +- ...rt-set-manager.ts => sort-rule-manager.ts} | 4 +- .../views/books/parts/sort-box.blade.php | 19 ++-- resources/views/books/sort.blade.php | 14 +-- .../settings/categories/sorting.blade.php | 17 ++-- .../create.blade.php | 6 +- .../{sort-sets => sort-rules}/edit.blade.php | 12 +-- .../parts/form.blade.php | 36 ++++---- .../parts/operation.blade.php | 0 .../parts/sort-rule-list-item.blade.php} | 8 +- routes/web.php | 12 +-- tests/Commands/AssignSortSetCommandTest.php | 22 ++--- tests/Entity/PageTest.php | 2 +- tests/Sorting/BookSortTest.php | 12 +-- .../{SortSetTest.php => SortRuleTest.php} | 90 +++++++++---------- 30 files changed, 232 insertions(+), 224 deletions(-) rename app/Sorting/{SortSet.php => SortRule.php} (77%) rename app/Sorting/{SortSetController.php => SortRuleController.php} (51%) rename app/Sorting/{SortSetOperation.php => SortRuleOperation.php} (74%) rename database/factories/Sorting/{SortSetFactory.php => SortRuleFactory.php} (70%) rename database/migrations/{2025_01_29_180933_create_sort_sets_table.php => 2025_01_29_180933_create_sort_rules_table.php} (82%) rename database/migrations/{2025_02_05_150842_add_sort_set_id_to_books.php => 2025_02_05_150842_add_sort_rule_id_to_books.php} (79%) rename resources/js/components/{sort-set-manager.ts => sort-rule-manager.ts} (93%) rename resources/views/settings/{sort-sets => sort-rules}/create.blade.php (70%) rename resources/views/settings/{sort-sets => sort-rules}/edit.blade.php (83%) rename resources/views/settings/{sort-sets => sort-rules}/parts/form.blade.php (53%) rename resources/views/settings/{sort-sets => sort-rules}/parts/operation.blade.php (100%) rename resources/views/settings/{sort-sets/parts/sort-set-list-item.blade.php => sort-rules/parts/sort-rule-list-item.blade.php} (52%) rename tests/Sorting/{SortSetTest.php => SortRuleTest.php} (58%) diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index 4a648da6c..a7f129f71 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -71,9 +71,9 @@ class ActivityType const IMPORT_RUN = 'import_run'; const IMPORT_DELETE = 'import_delete'; - const SORT_SET_CREATE = 'sort_set_create'; - const SORT_SET_UPDATE = 'sort_set_update'; - const SORT_SET_DELETE = 'sort_set_delete'; + const SORT_RULE_CREATE = 'sort_rule_create'; + const SORT_RULE_UPDATE = 'sort_rule_update'; + const SORT_RULE_DELETE = 'sort_rule_delete'; /** * Get all the possible values. diff --git a/app/Console/Commands/AssignSortSetCommand.php b/app/Console/Commands/AssignSortSetCommand.php index 484f69952..6c9d3f764 100644 --- a/app/Console/Commands/AssignSortSetCommand.php +++ b/app/Console/Commands/AssignSortSetCommand.php @@ -4,7 +4,7 @@ namespace BookStack\Console\Commands; use BookStack\Entities\Models\Book; use BookStack\Sorting\BookSorter; -use BookStack\Sorting\SortSet; +use BookStack\Sorting\SortRule; use Illuminate\Console\Command; class AssignSortSetCommand extends Command @@ -37,7 +37,7 @@ class AssignSortSetCommand extends Command return $this->listSortSets(); } - $set = SortSet::query()->find($sortSetId); + $set = SortRule::query()->find($sortSetId); if ($this->option('all-books')) { $query = Book::query(); } else if ($this->option('books-without-sort')) { @@ -87,7 +87,7 @@ class AssignSortSetCommand extends Command protected function listSortSets(): int { - $sets = SortSet::query()->orderBy('id', 'asc')->get(); + $sets = SortRule::query()->orderBy('id', 'asc')->get(); $this->error("Sort set ID required!"); $this->warn("\nAvailable sort sets:"); foreach ($sets as $set) { diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 7d240e5ca..ede4fc7d5 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -2,7 +2,7 @@ namespace BookStack\Entities\Models; -use BookStack\Sorting\SortSet; +use BookStack\Sorting\SortRule; use BookStack\Uploads\Image; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,14 +17,14 @@ use Illuminate\Support\Collection; * @property string $description * @property int $image_id * @property ?int $default_template_id - * @property ?int $sort_set_id + * @property ?int $sort_rule_id * @property Image|null $cover * @property \Illuminate\Database\Eloquent\Collection $chapters * @property \Illuminate\Database\Eloquent\Collection $pages * @property \Illuminate\Database\Eloquent\Collection $directPages * @property \Illuminate\Database\Eloquent\Collection $shelves * @property ?Page $defaultTemplate - * @property ?SortSet $sortSet + * @property ?SortRule $sortRule */ class Book extends Entity implements HasCoverImage { @@ -88,9 +88,9 @@ class Book extends Entity implements HasCoverImage /** * Get the sort set assigned to this book, if existing. */ - public function sortSet(): BelongsTo + public function sortRule(): BelongsTo { - return $this->belongsTo(SortSet::class); + return $this->belongsTo(SortRule::class); } /** diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index b3b811647..92e6a81c3 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -8,7 +8,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\ImageUploadException; use BookStack\Facades\Activity; -use BookStack\Sorting\SortSet; +use BookStack\Sorting\SortRule; use BookStack\Uploads\ImageRepo; use Exception; use Illuminate\Http\UploadedFile; @@ -35,8 +35,8 @@ class BookRepo Activity::add(ActivityType::BOOK_CREATE, $book); $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); - if ($defaultBookSortSetting && SortSet::query()->find($defaultBookSortSetting)) { - $book->sort_set_id = $defaultBookSortSetting; + if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { + $book->sort_rule_id = $defaultBookSortSetting; $book->save(); } diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php index 98d79d0fd..479d19724 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -69,10 +69,10 @@ class BookSortController extends Controller if ($request->filled('auto-sort')) { $sortSetId = intval($request->get('auto-sort')) ?: null; - if ($sortSetId && SortSet::query()->find($sortSetId) === null) { + if ($sortSetId && SortRule::query()->find($sortSetId) === null) { $sortSetId = null; } - $book->sort_set_id = $sortSetId; + $book->sort_rule_id = $sortSetId; $book->save(); $sorter->runBookAutoSort($book); if (!$loggedActivityForBook) { diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index b6fe33b9c..7bf1b63f4 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -16,7 +16,7 @@ class BookSorter ) { } - public function runBookAutoSortForAllWithSet(SortSet $set): void + public function runBookAutoSortForAllWithSet(SortRule $set): void { $set->books()->chunk(50, function ($books) { foreach ($books as $book) { @@ -32,12 +32,12 @@ class BookSorter */ public function runBookAutoSort(Book $book): void { - $set = $book->sortSet; + $set = $book->sortRule; if (!$set) { return; } - $sortFunctions = array_map(function (SortSetOperation $op) { + $sortFunctions = array_map(function (SortRuleOperation $op) { return $op->getSortFunction(); }, $set->getOperations()); diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortRule.php similarity index 77% rename from app/Sorting/SortSet.php rename to app/Sorting/SortRule.php index cc8879f96..45e5514fd 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortRule.php @@ -17,24 +17,24 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property Carbon $created_at * @property Carbon $updated_at */ -class SortSet extends Model implements Loggable +class SortRule extends Model implements Loggable { use HasFactory; /** - * @return SortSetOperation[] + * @return SortRuleOperation[] */ public function getOperations(): array { - return SortSetOperation::fromSequence($this->sequence); + return SortRuleOperation::fromSequence($this->sequence); } /** - * @param SortSetOperation[] $options + * @param SortRuleOperation[] $options */ public function setOperations(array $options): void { - $values = array_map(fn (SortSetOperation $opt) => $opt->value, $options); + $values = array_map(fn (SortRuleOperation $opt) => $opt->value, $options); $this->sequence = implode(',', $values); } @@ -45,7 +45,7 @@ class SortSet extends Model implements Loggable public function getUrl(): string { - return url("/settings/sorting/sets/{$this->id}"); + return url("/settings/sorting/rules/{$this->id}"); } public function books(): HasMany diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortRuleController.php similarity index 51% rename from app/Sorting/SortSetController.php rename to app/Sorting/SortRuleController.php index 7b1c0bc41..96b8e8ef5 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortRuleController.php @@ -6,7 +6,7 @@ use BookStack\Activity\ActivityType; use BookStack\Http\Controller; use Illuminate\Http\Request; -class SortSetController extends Controller +class SortRuleController extends Controller { public function __construct() { @@ -15,9 +15,9 @@ class SortSetController extends Controller public function create() { - $this->setPageTitle(trans('settings.sort_set_create')); + $this->setPageTitle(trans('settings.sort_rule_create')); - return view('settings.sort-sets.create'); + return view('settings.sort-rules.create'); } public function store(Request $request) @@ -27,28 +27,28 @@ class SortSetController extends Controller 'sequence' => ['required', 'string', 'min:1'], ]); - $operations = SortSetOperation::fromSequence($request->input('sequence')); + $operations = SortRuleOperation::fromSequence($request->input('sequence')); if (count($operations) === 0) { return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); } - $set = new SortSet(); - $set->name = $request->input('name'); - $set->setOperations($operations); - $set->save(); + $rule = new SortRule(); + $rule->name = $request->input('name'); + $rule->setOperations($operations); + $rule->save(); - $this->logActivity(ActivityType::SORT_SET_CREATE, $set); + $this->logActivity(ActivityType::SORT_RULE_CREATE, $rule); return redirect('/settings/sorting'); } public function edit(string $id) { - $set = SortSet::query()->findOrFail($id); + $rule = SortRule::query()->findOrFail($id); - $this->setPageTitle(trans('settings.sort_set_edit')); + $this->setPageTitle(trans('settings.sort_rule_edit')); - return view('settings.sort-sets.edit', ['set' => $set]); + return view('settings.sort-rules.edit', ['rule' => $rule]); } public function update(string $id, Request $request, BookSorter $bookSorter) @@ -58,21 +58,21 @@ class SortSetController extends Controller 'sequence' => ['required', 'string', 'min:1'], ]); - $set = SortSet::query()->findOrFail($id); - $operations = SortSetOperation::fromSequence($request->input('sequence')); + $rule = SortRule::query()->findOrFail($id); + $operations = SortRuleOperation::fromSequence($request->input('sequence')); if (count($operations) === 0) { - return redirect($set->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']); + return redirect($rule->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']); } - $set->name = $request->input('name'); - $set->setOperations($operations); - $changedSequence = $set->isDirty('sequence'); - $set->save(); + $rule->name = $request->input('name'); + $rule->setOperations($operations); + $changedSequence = $rule->isDirty('sequence'); + $rule->save(); - $this->logActivity(ActivityType::SORT_SET_UPDATE, $set); + $this->logActivity(ActivityType::SORT_RULE_UPDATE, $rule); if ($changedSequence) { - $bookSorter->runBookAutoSortForAllWithSet($set); + $bookSorter->runBookAutoSortForAllWithSet($rule); } return redirect('/settings/sorting'); @@ -80,16 +80,16 @@ class SortSetController extends Controller public function destroy(string $id, Request $request) { - $set = SortSet::query()->findOrFail($id); + $rule = SortRule::query()->findOrFail($id); $confirmed = $request->input('confirm') === 'true'; - $booksAssigned = $set->books()->count(); + $booksAssigned = $rule->books()->count(); $warnings = []; if ($booksAssigned > 0) { if ($confirmed) { - $set->books()->update(['sort_set_id' => null]); + $rule->books()->update(['sort_rule_id' => null]); } else { - $warnings[] = trans('settings.sort_set_delete_warn_books', ['count' => $booksAssigned]); + $warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]); } } @@ -98,16 +98,16 @@ class SortSetController extends Controller if ($confirmed) { setting()->remove('sorting-book-default'); } else { - $warnings[] = trans('settings.sort_set_delete_warn_default'); + $warnings[] = trans('settings.sort_rule_delete_warn_default'); } } if (count($warnings) > 0) { - return redirect($set->getUrl() . '#delete')->withErrors(['delete' => $warnings]); + return redirect($rule->getUrl() . '#delete')->withErrors(['delete' => $warnings]); } - $set->delete(); - $this->logActivity(ActivityType::SORT_SET_DELETE, $set); + $rule->delete(); + $this->logActivity(ActivityType::SORT_RULE_DELETE, $rule); return redirect('/settings/sorting'); } diff --git a/app/Sorting/SortSetOperation.php b/app/Sorting/SortRuleOperation.php similarity index 74% rename from app/Sorting/SortSetOperation.php rename to app/Sorting/SortRuleOperation.php index 7fdd0b002..0d8ff239f 100644 --- a/app/Sorting/SortSetOperation.php +++ b/app/Sorting/SortRuleOperation.php @@ -5,7 +5,7 @@ namespace BookStack\Sorting; use Closure; use Illuminate\Support\Str; -enum SortSetOperation: string +enum SortRuleOperation: string { case NameAsc = 'name_asc'; case NameDesc = 'name_desc'; @@ -26,13 +26,13 @@ enum SortSetOperation: string $label = ''; if (str_ends_with($key, '_asc')) { $key = substr($key, 0, -4); - $label = trans('settings.sort_set_op_asc'); + $label = trans('settings.sort_rule_op_asc'); } elseif (str_ends_with($key, '_desc')) { $key = substr($key, 0, -5); - $label = trans('settings.sort_set_op_desc'); + $label = trans('settings.sort_rule_op_desc'); } - $label = trans('settings.sort_set_op_' . $key) . ' ' . $label; + $label = trans('settings.sort_rule_op_' . $key) . ' ' . $label; return trim($label); } @@ -43,12 +43,12 @@ enum SortSetOperation: string } /** - * @return SortSetOperation[] + * @return SortRuleOperation[] */ public static function allExcluding(array $operations): array { - $all = SortSetOperation::cases(); - $filtered = array_filter($all, function (SortSetOperation $operation) use ($operations) { + $all = SortRuleOperation::cases(); + $filtered = array_filter($all, function (SortRuleOperation $operation) use ($operations) { return !in_array($operation, $operations); }); return array_values($filtered); @@ -57,12 +57,12 @@ enum SortSetOperation: string /** * Create a set of operations from a string sequence representation. * (values seperated by commas). - * @return SortSetOperation[] + * @return SortRuleOperation[] */ public static function fromSequence(string $sequence): array { $strOptions = explode(',', $sequence); - $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); + $options = array_map(fn ($val) => SortRuleOperation::tryFrom($val), $strOptions); return array_filter($options); } } diff --git a/database/factories/Entities/Models/BookFactory.php b/database/factories/Entities/Models/BookFactory.php index 29403a294..48d43d7a8 100644 --- a/database/factories/Entities/Models/BookFactory.php +++ b/database/factories/Entities/Models/BookFactory.php @@ -27,7 +27,7 @@ class BookFactory extends Factory 'slug' => Str::random(10), 'description' => $description, 'description_html' => '

    ' . e($description) . '

    ', - 'sort_set_id' => null, + 'sort_rule_id' => null, 'default_template_id' => null, ]; } diff --git a/database/factories/Sorting/SortSetFactory.php b/database/factories/Sorting/SortRuleFactory.php similarity index 70% rename from database/factories/Sorting/SortSetFactory.php rename to database/factories/Sorting/SortRuleFactory.php index 36e0a6976..dafe8c3fa 100644 --- a/database/factories/Sorting/SortSetFactory.php +++ b/database/factories/Sorting/SortRuleFactory.php @@ -2,25 +2,25 @@ namespace Database\Factories\Sorting; -use BookStack\Sorting\SortSet; -use BookStack\Sorting\SortSetOperation; +use BookStack\Sorting\SortRule; +use BookStack\Sorting\SortRuleOperation; use Illuminate\Database\Eloquent\Factories\Factory; -class SortSetFactory extends Factory +class SortRuleFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ - protected $model = SortSet::class; + protected $model = SortRule::class; /** * Define the model's default state. */ public function definition(): array { - $cases = SortSetOperation::cases(); + $cases = SortRuleOperation::cases(); $op = $cases[array_rand($cases)]; return [ 'name' => $op->name . ' Sort', diff --git a/database/migrations/2025_01_29_180933_create_sort_sets_table.php b/database/migrations/2025_01_29_180933_create_sort_rules_table.php similarity index 82% rename from database/migrations/2025_01_29_180933_create_sort_sets_table.php rename to database/migrations/2025_01_29_180933_create_sort_rules_table.php index bf9780c5b..37d20ddf6 100644 --- a/database/migrations/2025_01_29_180933_create_sort_sets_table.php +++ b/database/migrations/2025_01_29_180933_create_sort_rules_table.php @@ -11,7 +11,7 @@ return new class extends Migration */ public function up(): void { - Schema::create('sort_sets', function (Blueprint $table) { + Schema::create('sort_rules', function (Blueprint $table) { $table->increments('id'); $table->string('name'); $table->text('sequence'); @@ -24,6 +24,6 @@ return new class extends Migration */ public function down(): void { - Schema::dropIfExists('sort_sets'); + Schema::dropIfExists('sort_rules'); } }; diff --git a/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php b/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php similarity index 79% rename from database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php rename to database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php index c0b32c552..106db05ca 100644 --- a/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php +++ b/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php @@ -12,7 +12,7 @@ return new class extends Migration public function up(): void { Schema::table('books', function (Blueprint $table) { - $table->unsignedInteger('sort_set_id')->nullable()->default(null); + $table->unsignedInteger('sort_rule_id')->nullable()->default(null); }); } @@ -22,7 +22,7 @@ return new class extends Migration public function down(): void { Schema::table('books', function (Blueprint $table) { - $table->dropColumn('sort_set_id'); + $table->dropColumn('sort_rule_id'); }); } }; diff --git a/lang/en/entities.php b/lang/en/entities.php index 28a209fa2..a74785eaa 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -166,7 +166,7 @@ return [ 'books_search_this' => 'Search this book', 'books_navigation' => 'Book Navigation', 'books_sort' => 'Sort Book Contents', - 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort option can be set to automatically sort this book\'s contents upon changes.', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.', 'books_sort_auto_sort' => 'Auto Sort Option', 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', 'books_sort_named' => 'Sort Book :bookName', diff --git a/lang/en/settings.php b/lang/en/settings.php index 344c186cb..098479f3b 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -77,32 +77,32 @@ return [ // Sorting Settings 'sorting' => 'Sorting', 'sorting_book_default' => 'Default Book Sort', - 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', - 'sorting_sets' => 'Sort Sets', - 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', - 'sort_set_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', - 'sort_set_create' => 'Create Sort Set', - 'sort_set_edit' => 'Edit Sort Set', - 'sort_set_delete' => 'Delete Sort Set', - 'sort_set_delete_desc' => 'Remove this sort set from the system. Books using this sort will revert to manual sorting.', - 'sort_set_delete_warn_books' => 'This sort set is currently used on :count book(s). Are you sure you want to delete this?', - 'sort_set_delete_warn_default' => 'This sort set is currently used as the default for books. Are you sure you want to delete this?', - 'sort_set_details' => 'Sort Set Details', - 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', - 'sort_set_operations' => 'Sort Operations', - 'sort_set_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', - 'sort_set_available_operations' => 'Available Operations', - 'sort_set_available_operations_empty' => 'No operations remaining', - 'sort_set_configured_operations' => 'Configured Operations', - 'sort_set_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', - 'sort_set_op_asc' => '(Asc)', - 'sort_set_op_desc' => '(Desc)', - 'sort_set_op_name' => 'Name - Alphabetical', - 'sort_set_op_name_numeric' => 'Name - Numeric', - 'sort_set_op_created_date' => 'Created Date', - 'sort_set_op_updated_date' => 'Updated Date', - 'sort_set_op_chapters_first' => 'Chapters First', - 'sort_set_op_chapters_last' => 'Chapters Last', + 'sorting_book_default_desc' => 'Select the default sort role to apply to new books. This won\'t affect existing books, and can be overridden per-book.', + 'sorting_rules' => 'Sort Rules', + 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + 'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', + 'sort_rule_create' => 'Create Sort Rule', + 'sort_rule_edit' => 'Edit Sort Rule', + 'sort_rule_delete' => 'Delete Sort Rule', + 'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.', + 'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?', + 'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?', + 'sort_rule_details' => 'Sort Rule Details', + 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.', + 'sort_rule_operations' => 'Sort Operations', + 'sort_rule_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', + 'sort_rule_available_operations' => 'Available Operations', + 'sort_rule_available_operations_empty' => 'No operations remaining', + 'sort_rule_configured_operations' => 'Configured Operations', + 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', + 'sort_rule_op_asc' => '(Asc)', + 'sort_rule_op_desc' => '(Desc)', + 'sort_rule_op_name' => 'Name - Alphabetical', + 'sort_rule_op_name_numeric' => 'Name - Numeric', + 'sort_rule_op_created_date' => 'Created Date', + 'sort_rule_op_updated_date' => 'Updated Date', + 'sort_rule_op_chapters_first' => 'Chapters First', + 'sort_rule_op_chapters_last' => 'Chapters Last', // Maintenance settings 'maint' => 'Maintenance', diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index affa25fcf..10b8025db 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -50,7 +50,7 @@ export {ShelfSort} from './shelf-sort'; export {Shortcuts} from './shortcuts'; export {ShortcutInput} from './shortcut-input'; export {SortableList} from './sortable-list'; -export {SortSetManager} from './sort-set-manager' +export {SortRuleManager} from './sort-rule-manager' export {SubmitOnChange} from './submit-on-change'; export {Tabs} from './tabs'; export {TagManager} from './tag-manager'; diff --git a/resources/js/components/sort-set-manager.ts b/resources/js/components/sort-rule-manager.ts similarity index 93% rename from resources/js/components/sort-set-manager.ts rename to resources/js/components/sort-rule-manager.ts index c35ad41fe..ff08f4ab8 100644 --- a/resources/js/components/sort-set-manager.ts +++ b/resources/js/components/sort-rule-manager.ts @@ -3,7 +3,7 @@ import Sortable from "sortablejs"; import {buildListActions, sortActionClickListener} from "../services/dual-lists"; -export class SortSetManager extends Component { +export class SortRuleManager extends Component { protected input!: HTMLInputElement; protected configuredList!: HTMLElement; @@ -25,7 +25,7 @@ export class SortSetManager extends Component { const scrollBoxes = [this.configuredList, this.availableList]; for (const scrollBox of scrollBoxes) { new Sortable(scrollBox, { - group: 'sort-set-operations', + group: 'sort-rule-operations', ghostClass: 'primary-background-light', handle: '.handle', animation: 150, diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index 232616168..6fdb1819e 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -9,18 +9,23 @@ {{ $book->name }}
    - @if($book->sortSet) - @icon('auto-sort') + @if($book->sortRule) + @icon('auto-sort') @endif
    - - - - - + + + + +
      diff --git a/resources/views/books/sort.blade.php b/resources/views/books/sort.blade.php index 3c59ac1e0..e090708b1 100644 --- a/resources/views/books/sort.blade.php +++ b/resources/views/books/sort.blade.php @@ -23,19 +23,21 @@

      {{ trans('entities.books_sort_desc') }}

      @php - $autoSortVal = intval(old('auto-sort') ?? $book->sort_set_id ?? 0); + $autoSortVal = intval(old('auto-sort') ?? $book->sort_rule_id ?? 0); @endphp diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 6a52873e6..9d1d9814b 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,7 +1,7 @@ @extends('settings.layout') @php - $sortSets = \BookStack\Sorting\SortSet::allByName(); + $sortRules = \BookStack\Sorting\SortRule::allByName(); @endphp @section('card') @@ -23,7 +23,7 @@ - @foreach($sortSets as $set) + @foreach($sortRules as $set)