From e3fcd26f12ccda464cb22bc4e95bad108878bca0 Mon Sep 17 00:00:00 2001 From: Claudio Valdez Date: Wed, 11 Mar 2026 12:30:59 -0300 Subject: [PATCH 01/69] Add mfa reset button for admin s on user profile edit --- app/Users/Controllers/UserController.php | 13 +++++++++++++ lang/en/settings.php | 5 +++++ resources/views/users/edit.blade.php | 20 ++++++++++++++++++++ routes/web.php | 1 + 4 files changed, 39 insertions(+) diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index 494221b14..6d1a47c0d 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -208,4 +208,17 @@ class UserController extends Controller return redirect('/settings/users'); } + + /** + * Reset MFA for the specified user. + */ + public function resetMfa(Request $request, int $id) + { + $this->checkPermission(Permission::UsersManage); + $user = $this->userRepo->getById($id); + // Resetear el 2FA del usuario + $user->mfaValues()->delete(); + session()->flash('success', trans('settings.users_mfa_reset_success', ['userName' => $user->name])); + return redirect()->back(); + } } diff --git a/lang/en/settings.php b/lang/en/settings.php index c4d1eb136..8499aed6f 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -263,6 +263,11 @@ return [ 'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', 'users_mfa_x_methods' => ':count method configured|:count methods configured', 'users_mfa_configure' => 'Configure Methods', + 'users_mfa_reset' => 'Reset 2FA', + 'users_mfa_reset_desc' => 'Reset and clear all configured MFA methods for :userName. They will be prompted to reconfigure on next login.', + 'users_mfa_reset_confirm' => 'Are you sure you want to reset 2FA for :userName?', + 'users_mfa_reset_success' => '2FA has been reset for :userName', + 'users_mfa_reset_error' => 'Failed to reset 2FA for :userName', // API Tokens 'user_api_token_create' => 'Create API Token', diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 611653d6a..64d45f503 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -71,6 +71,26 @@ + @if(user()->hasSystemRole('admin')) +
+
+
+
+ {{ trans('settings.users_mfa_reset') }} +

{{ trans('settings.users_mfa_reset_desc', ['userName' => $user->name]) }}

+
+
+
id}/reset-mfa") }}" method="POST" style="display: inline;"> + @csrf + +
+
+
+
+ @endif @if(count($activeSocialDrivers) > 0) diff --git a/routes/web.php b/routes/web.php index a20c0a3d3..2571da2f3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -251,6 +251,7 @@ Route::middleware('auth')->group(function () { Route::get('/settings/users/{id}', [UserControllers\UserController::class, 'edit']); Route::put('/settings/users/{id}', [UserControllers\UserController::class, 'update']); Route::delete('/settings/users/{id}', [UserControllers\UserController::class, 'destroy']); + Route::post('/settings/users/{id}/reset-mfa', [UserControllers\UserController::class, 'resetMfa']); // User Account Route::get('/my-account', [UserControllers\UserAccountController::class, 'redirect']); From 0b659671fe08da3a699f0b4ddd93e77e18d13b11 Mon Sep 17 00:00:00 2001 From: ololukaszuk <47779810+ololukaszuk@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:23:15 +0100 Subject: [PATCH 02/69] Fix PDF heading font fallback for export --- resources/sass/export-styles.scss | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/resources/sass/export-styles.scss b/resources/sass/export-styles.scss index 8dd7be375..22f15d1b8 100644 --- a/resources/sass/export-styles.scss +++ b/resources/sass/export-styles.scss @@ -64,6 +64,11 @@ body.export-format-pdf { font-size: 14px; line-height: 1.2; + // Ensure heading glyph coverage for PDF engines that don't handle CSS vars well. + h1, h2, h3, h4, h5, h6 { + font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + } + h1, h2, h3, h4, h5, h6 { line-height: 1.2; } @@ -100,4 +105,4 @@ body.export-format-pdf.export-engine-dompdf { .page-content td a > img { max-width: 100%; } -} \ No newline at end of file +} From c7d3775bb999bda0d134de900b28f2ebeb4cec67 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 5 Apr 2026 00:05:10 +0100 Subject: [PATCH 03/69] Plain text: Created a new HTML to plain text converter To centralise logic to be more consistent, and to have smarter logic which avoids just following newline format from input, preventing smushing HTML elements (like list elements) next to eachother --- app/Activity/Models/Comment.php | 7 +++ .../Messages/CommentCreationNotification.php | 2 +- .../Messages/CommentMentionNotification.php | 2 +- app/Entities/Repos/BaseRepo.php | 4 +- app/Entities/Tools/PageContent.php | 5 +- app/Util/HtmlToPlainText.php | 47 ++++++++++++++ tests/Util/HtmlToPlainTextTest.php | 63 +++++++++++++++++++ 7 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 app/Util/HtmlToPlainText.php create mode 100644 tests/Util/HtmlToPlainTextTest.php diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index ab7d91772..3faa76657 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -9,6 +9,7 @@ use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\OwnableInterface; use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilterConfig; +use BookStack\Util\HtmlToPlainText; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -87,6 +88,12 @@ class Comment extends Model implements Loggable, OwnableInterface return $filter->filterString($this->html ?? ''); } + public function getPlainText(): string + { + $converter = new HtmlToPlainText(); + return $converter->convert($this->html ?? ''); + } + public function jointPermissions(): HasMany { return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id') diff --git a/app/Activity/Notifications/Messages/CommentCreationNotification.php b/app/Activity/Notifications/Messages/CommentCreationNotification.php index 30d0ffa2b..d739f4aab 100644 --- a/app/Activity/Notifications/Messages/CommentCreationNotification.php +++ b/app/Activity/Notifications/Messages/CommentCreationNotification.php @@ -24,7 +24,7 @@ class CommentCreationNotification extends BaseActivityNotification $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), $locale->trans('notifications.detail_commenter') => $this->user->name, - $locale->trans('notifications.detail_comment') => strip_tags($comment->html), + $locale->trans('notifications.detail_comment') => $comment->getPlainText(), ]); return $this->newMailMessage($locale) diff --git a/app/Activity/Notifications/Messages/CommentMentionNotification.php b/app/Activity/Notifications/Messages/CommentMentionNotification.php index de9e71963..4c8ee5bab 100644 --- a/app/Activity/Notifications/Messages/CommentMentionNotification.php +++ b/app/Activity/Notifications/Messages/CommentMentionNotification.php @@ -24,7 +24,7 @@ class CommentMentionNotification extends BaseActivityNotification $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), $locale->trans('notifications.detail_commenter') => $this->user->name, - $locale->trans('notifications.detail_comment') => strip_tags($comment->html), + $locale->trans('notifications.detail_comment') => $comment->getPlainText(), ]); return $this->newMailMessage($locale) diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 717e9c9f8..44baeaccf 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -16,6 +16,7 @@ use BookStack\References\ReferenceUpdater; use BookStack\Sorting\BookSorter; use BookStack\Uploads\ImageRepo; use BookStack\Util\HtmlDescriptionFilter; +use BookStack\Util\HtmlToPlainText; use Illuminate\Http\UploadedFile; class BaseRepo @@ -151,9 +152,10 @@ class BaseRepo } if (isset($input['description_html'])) { + $plainTextConverter = new HtmlToPlainText(); $entity->descriptionInfo()->set( HtmlDescriptionFilter::filterFromString($input['description_html']), - html_entity_decode(strip_tags($input['description_html'])) + $plainTextConverter->convert($input['description_html']), ); } else if (isset($input['description'])) { $entity->descriptionInfo()->set('', $input['description']); diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 8d89a86cf..b86fbbe8b 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -16,6 +16,7 @@ use BookStack\Users\Models\User; use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilterConfig; use BookStack\Util\HtmlDocument; +use BookStack\Util\HtmlToPlainText; use BookStack\Util\WebSafeMimeSniffer; use Closure; use DOMElement; @@ -303,8 +304,8 @@ class PageContent public function toPlainText(): string { $html = $this->render(true); - - return html_entity_decode(strip_tags($html)); + $converter = new HtmlToPlainText(); + return $converter->convert($html); } /** diff --git a/app/Util/HtmlToPlainText.php b/app/Util/HtmlToPlainText.php new file mode 100644 index 000000000..79da9e3d8 --- /dev/null +++ b/app/Util/HtmlToPlainText.php @@ -0,0 +1,47 @@ +nodeToText($doc->getBody()); + + // Remove repeated newlines + $text = preg_replace('/\n+/', "\n", $text); + // Remove leading/trailing whitespace + $text = trim($text); + + return $text; + } + + protected function nodeToText(\DOMNode $node): string + { + if ($node->nodeType === XML_TEXT_NODE) { + return $node->textContent; + } + + $text = ''; + if (!in_array($node->nodeName, $this->inlineTags)) { + $text .= "\n"; + } + + foreach ($node->childNodes as $childNode) { + $text .= $this->nodeToText($childNode); + } + + return $text; + } +} diff --git a/tests/Util/HtmlToPlainTextTest.php b/tests/Util/HtmlToPlainTextTest.php new file mode 100644 index 000000000..e522e4863 --- /dev/null +++ b/tests/Util/HtmlToPlainTextTest.php @@ -0,0 +1,63 @@ +This is a test

+ +

A Header

+

more <©> text with bold

+HTML; + $expected = << text with bold +TEXT; + + $this->runTest($html, $expected); + } + + public function test_adjacent_list_items_are_separated_by_newline() + { + $html = <<
  • Item A
  • Item B
  • +HTML; + $expected = <<runTest($html, $expected); + } + + public function test_inline_formats_dont_cause_newlines() + { + $html = <<Hello

    +HTML; + $expected = <<runTest($html, $expected); + } + + protected function runTest(string $html, string $expected): void + { + $converter = new HtmlToPlainText(); + $result = $converter->convert(trim($html)); + $this->assertEquals(trim($expected), $result); + } +} From abed4eae0c541a44990d260796e21e420d8243ad Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 5 Apr 2026 17:51:19 +0100 Subject: [PATCH 04/69] Exports: Updated plaintext export to use new converter --- app/Exports/ExportFormatter.php | 20 +++++--------------- tests/Exports/TextExportTest.php | 4 ++-- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/app/Exports/ExportFormatter.php b/app/Exports/ExportFormatter.php index c5973eace..dec8aa23d 100644 --- a/app/Exports/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -11,6 +11,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Uploads\ImageService; use BookStack\Util\CspService; use BookStack\Util\HtmlDocument; +use BookStack\Util\HtmlToPlainText; use DOMElement; use Exception; use Throwable; @@ -242,24 +243,13 @@ class ExportFormatter /** * Converts the page contents into simple plain text. - * This method filters any bad looking content to provide a nice final output. + * We re-generate the plain text from HTML at this point, post-page-content rendering. */ public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string { $html = $pageRendered ? $page->html : (new PageContent($page))->render(); - // Add proceeding spaces before tags so spaces remain between - // text within elements after stripping tags. - $html = str_replace('<', " <", $html); - $text = trim(strip_tags($html)); - // Replace multiple spaces with single spaces - $text = preg_replace('/ {2,}/', ' ', $text); - // Reduce multiple horrid whitespace characters. - $text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text); - $text = html_entity_decode($text); - // Add title - $text = $page->name . ($fromParent ? "\n" : "\n\n") . $text; - - return $text; + $contentText = (new HtmlToPlainText())->convert($html); + return $page->name . ($fromParent ? "\n" : "\n\n") . $contentText; } /** @@ -267,7 +257,7 @@ class ExportFormatter */ public function chapterToPlainText(Chapter $chapter): string { - $text = $chapter->name . "\n" . $chapter->description; + $text = $chapter->name . "\n" . $chapter->descriptionInfo()->getPlain(); $text = trim($text) . "\n\n"; $parts = []; diff --git a/tests/Exports/TextExportTest.php b/tests/Exports/TextExportTest.php index 4b2d62887..26298c185 100644 --- a/tests/Exports/TextExportTest.php +++ b/tests/Exports/TextExportTest.php @@ -52,7 +52,7 @@ class TextExportTest extends TestCase $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; - $expected .= "My wonderful page!\nMy great page Full of great stuff"; + $expected .= "My wonderful page!\nMy great page\nFull of great stuff"; $resp->assertSee($expected); } @@ -82,7 +82,7 @@ class TextExportTest extends TestCase $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; - $expected .= "My wonderful page!\nMy great page Full of great stuff"; + $expected .= "My wonderful page!\nMy great page\nFull of great stuff"; $resp->assertSee($expected); } } From b9d650785aee9be64a7e004c35bfa105de911a39 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 5 Apr 2026 22:28:27 +0100 Subject: [PATCH 05/69] Deps: Updated PHP package versions --- composer.lock | 341 +++++++++++++++++++++++++------------------------- 1 file changed, 171 insertions(+), 170 deletions(-) diff --git a/composer.lock b/composer.lock index d6069720d..a70aa9a0f 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.373.7", + "version": "3.376.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "4402bd10f913e66b7271f44466be8d5ba6c9146e" + "reference": "2081f8db174df4bb8842aed3b7b513590ee9d219" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/4402bd10f913e66b7271f44466be8d5ba6c9146e", - "reference": "4402bd10f913e66b7271f44466be8d5ba6c9146e", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2081f8db174df4bb8842aed3b7b513590ee9d219", + "reference": "2081f8db174df4bb8842aed3b7b513590ee9d219", "shasum": "" }, "require": { @@ -153,22 +153,22 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.373.7" + "source": "https://github.com/aws/aws-sdk-php/tree/3.376.3" }, - "time": "2026-03-20T18:14:19+00:00" + "time": "2026-04-03T18:07:33+00:00" }, { "name": "bacon/bacon-qr-code", - "version": "v3.0.4", + "version": "v3.1.1", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "3feed0e212b8412cc5d2612706744789b0615824" + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824", - "reference": "3feed0e212b8412cc5d2612706744789b0615824", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", "shasum": "" }, "require": { @@ -208,9 +208,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" }, - "time": "2026-03-16T01:01:30+00:00" + "time": "2026-04-05T21:06:35+00:00" }, { "name": "brick/math", @@ -982,16 +982,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.3", + "version": "v7.0.5", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", - "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", "shasum": "" }, "require": { @@ -999,6 +999,7 @@ }, "require-dev": { "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", @@ -1039,9 +1040,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" + "source": "https://github.com/firebase/php-jwt/tree/v7.0.5" }, - "time": "2026-02-25T22:16:40+00:00" + "time": "2026-04-01T20:38:03+00:00" }, { "name": "fruitcake/php-cors", @@ -1801,16 +1802,16 @@ }, { "name": "laravel/framework", - "version": "v12.55.1", + "version": "v12.56.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33" + "reference": "dac16d424b59debb2273910dde88eb7050a2a709" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33", - "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33", + "url": "https://api.github.com/repos/laravel/framework/zipball/dac16d424b59debb2273910dde88eb7050a2a709", + "reference": "dac16d424b59debb2273910dde88eb7050a2a709", "shasum": "" }, "require": { @@ -2019,20 +2020,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-18T14:28:59+00:00" + "time": "2026-03-26T14:51:54+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.15", + "version": "v0.3.16", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "4bb8107ec97651fd3f17f897d6489dbc4d8fb999" + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/4bb8107ec97651fd3f17f897d6489dbc4d8fb999", - "reference": "4bb8107ec97651fd3f17f897d6489dbc4d8fb999", + "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", "shasum": "" }, "require": { @@ -2076,9 +2077,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.15" + "source": "https://github.com/laravel/prompts/tree/v0.3.16" }, - "time": "2026-03-17T13:45:17+00:00" + "time": "2026-03-23T14:35:33+00:00" }, { "name": "laravel/serializable-closure", @@ -2143,16 +2144,16 @@ }, { "name": "laravel/socialite", - "version": "v5.25.0", + "version": "v5.26.1", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "231f572e1a37c9ca1fb8085e9fb8608285beafb3" + "reference": "db6ec2ee967b7f06412c3a0cf1daaf072f4752a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/231f572e1a37c9ca1fb8085e9fb8608285beafb3", - "reference": "231f572e1a37c9ca1fb8085e9fb8608285beafb3", + "url": "https://api.github.com/repos/laravel/socialite/zipball/db6ec2ee967b7f06412c3a0cf1daaf072f4752a4", + "reference": "db6ec2ee967b7f06412c3a0cf1daaf072f4752a4", "shasum": "" }, "require": { @@ -2211,7 +2212,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-02-27T13:56:35+00:00" + "time": "2026-03-29T14:50:53+00:00" }, { "name": "laravel/tinker", @@ -2470,16 +2471,16 @@ }, { "name": "league/flysystem", - "version": "3.32.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" + "reference": "570b8871e0ce693764434b29154c54b434905350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", "shasum": "" }, "require": { @@ -2547,9 +2548,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" }, - "time": "2026-02-25T17:01:41+00:00" + "time": "2026-03-25T07:59:30+00:00" }, { "name": "league/flysystem-aws-s3-v3", @@ -4664,16 +4665,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.21", + "version": "v0.12.22", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97" + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", - "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", "shasum": "" }, "require": { @@ -4737,9 +4738,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.21" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" }, - "time": "2026-03-06T21:21:28+00:00" + "time": "2026-03-22T23:03:24+00:00" }, { "name": "ralouphie/getallheaders", @@ -5431,16 +5432,16 @@ }, { "name": "symfony/clock", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", "shasum": "" }, "require": { @@ -5485,7 +5486,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.4.0" + "source": "https://github.com/symfony/clock/tree/v7.4.8" }, "funding": [ { @@ -5505,20 +5506,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:39:26+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/console", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", "shasum": "" }, "require": { @@ -5583,7 +5584,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.7" + "source": "https://github.com/symfony/console/tree/v7.4.8" }, "funding": [ { @@ -5603,20 +5604,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:20+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { "name": "symfony/css-selector", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2e7c52c647b406e2107dd867db424a4dbac91864" + "reference": "b055f228a4178a1d6774909903905e3475f3eac8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2e7c52c647b406e2107dd867db424a4dbac91864", - "reference": "2e7c52c647b406e2107dd867db424a4dbac91864", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b055f228a4178a1d6774909903905e3475f3eac8", + "reference": "b055f228a4178a1d6774909903905e3475f3eac8", "shasum": "" }, "require": { @@ -5652,7 +5653,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.6" + "source": "https://github.com/symfony/css-selector/tree/v7.4.8" }, "funding": [ { @@ -5672,7 +5673,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5743,16 +5744,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", "shasum": "" }, "require": { @@ -5801,7 +5802,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" }, "funding": [ { @@ -5821,20 +5822,20 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746" + "reference": "f57b899fa736fd71121168ef268f23c206083f0a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f57b899fa736fd71121168ef268f23c206083f0a", + "reference": "f57b899fa736fd71121168ef268f23c206083f0a", "shasum": "" }, "require": { @@ -5886,7 +5887,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.8" }, "funding": [ { @@ -5906,7 +5907,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:34+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5986,16 +5987,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5", + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5", "shasum": "" }, "require": { @@ -6032,7 +6033,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.6" + "source": "https://github.com/symfony/filesystem/tree/v7.4.8" }, "funding": [ { @@ -6052,20 +6053,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "e0be088d22278583a82da281886e8c3592fbf149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", "shasum": "" }, "require": { @@ -6100,7 +6101,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v7.4.8" }, "funding": [ { @@ -6120,20 +6121,20 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" + "reference": "9381209597ec66c25be154cbf2289076e64d1eab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", + "reference": "9381209597ec66c25be154cbf2289076e64d1eab", "shasum": "" }, "require": { @@ -6182,7 +6183,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" }, "funding": [ { @@ -6202,20 +6203,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" + "reference": "017e76ad089bac281553389269e259e155935e1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/017e76ad089bac281553389269e259e155935e1a", + "reference": "017e76ad089bac281553389269e259e155935e1a", "shasum": "" }, "require": { @@ -6301,7 +6302,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.8" }, "funding": [ { @@ -6321,20 +6322,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T16:33:18+00:00" + "time": "2026-03-31T20:57:01+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f6ea532250b476bfc1b56699b388a1bdbf168f62", + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62", "shasum": "" }, "require": { @@ -6385,7 +6386,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v7.4.8" }, "funding": [ { @@ -6405,20 +6406,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/mime", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" + "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "url": "https://api.github.com/repos/symfony/mime/zipball/6df02f99998081032da3407a8d6c4e1dcb5d4379", + "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379", "shasum": "" }, "require": { @@ -6474,7 +6475,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.7" + "source": "https://github.com/symfony/mime/tree/v7.4.8" }, "funding": [ { @@ -6494,7 +6495,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T15:24:09+00:00" + "time": "2026-03-30T14:11:46+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7327,16 +7328,16 @@ }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", "shasum": "" }, "require": { @@ -7368,7 +7369,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.8" }, "funding": [ { @@ -7388,20 +7389,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", + "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", "shasum": "" }, "require": { @@ -7453,7 +7454,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v7.4.8" }, "funding": [ { @@ -7473,7 +7474,7 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/service-contracts", @@ -7564,16 +7565,16 @@ }, { "name": "symfony/string", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" + "reference": "114ac57257d75df748eda23dd003878080b8e688" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", - "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", + "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688", + "reference": "114ac57257d75df748eda23dd003878080b8e688", "shasum": "" }, "require": { @@ -7631,7 +7632,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.6" + "source": "https://github.com/symfony/string/tree/v7.4.8" }, "funding": [ { @@ -7651,20 +7652,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/translation", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "1888cf064399868af3784b9e043240f1d89d25ce" + "reference": "33600f8489485425bfcddd0d983391038d3422e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/1888cf064399868af3784b9e043240f1d89d25ce", - "reference": "1888cf064399868af3784b9e043240f1d89d25ce", + "url": "https://api.github.com/repos/symfony/translation/zipball/33600f8489485425bfcddd0d983391038d3422e7", + "reference": "33600f8489485425bfcddd0d983391038d3422e7", "shasum": "" }, "require": { @@ -7731,7 +7732,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.6" + "source": "https://github.com/symfony/translation/tree/v7.4.8" }, "funding": [ { @@ -7751,7 +7752,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/translation-contracts", @@ -7837,16 +7838,16 @@ }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/6883ebdf7bf6a12b37519dbc0df62b0222401b56", + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56", "shasum": "" }, "require": { @@ -7891,7 +7892,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v7.4.8" }, "funding": [ { @@ -7911,20 +7912,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", "shasum": "" }, "require": { @@ -7978,7 +7979,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" }, "funding": [ { @@ -7998,7 +7999,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-03-30T13:44:50+00:00" }, { "name": "thecodingmachine/safe", @@ -8955,23 +8956,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.1", + "version": "v8.9.2", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + "reference": "6eb16883e74fd725ac64dbe81544c961ab448ba5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/6eb16883e74fd725ac64dbe81544c961ab448ba5", + "reference": "6eb16883e74fd725ac64dbe81544c961ab448ba5", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.4 || ^8.0.4" + "symfony/console": "^7.4.8 || ^8.0.4" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -8979,12 +8980,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.2", - "laravel/framework": "^11.48.0 || ^12.52.0", - "laravel/pint": "^1.27.1", - "orchestra/testbench-core": "^9.12.0 || ^10.9.0", - "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + "larastan/larastan": "^3.9.3", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.2.0", + "laravel/pint": "^1.29.0", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.0.0", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.0.0" }, "type": "library", "extra": { @@ -9047,7 +9048,7 @@ "type": "patreon" } ], - "time": "2026-02-17T17:33:08+00:00" + "time": "2026-03-31T21:51:27+00:00" }, { "name": "phar-io/manifest", @@ -9169,11 +9170,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.42", + "version": "2.1.46", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", - "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", "shasum": "" }, "require": { @@ -9218,7 +9219,7 @@ "type": "github" } ], - "time": "2026-03-17T14:58:32+00:00" + "time": "2026-04-01T09:25:14+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10841,16 +10842,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e" + "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/487ba8fa43da9a8e6503fe939b45ecd96875410e", - "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2918e7c2ba964defca1f5b69c6f74886529e2dc8", + "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8", "shasum": "" }, "require": { @@ -10889,7 +10890,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.4.6" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.8" }, "funding": [ { @@ -10909,7 +10910,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "theseer/tokenizer", From a7dd998ac9ef4614966d690813472ab32b7c8e58 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 5 Apr 2026 22:29:00 +0100 Subject: [PATCH 06/69] Updated translations with latest Crowdin changes (#6067) --- lang/da/editor.php | 2 +- lang/da/entities.php | 8 +- lang/da/errors.php | 2 +- lang/da/settings.php | 2 +- lang/de/entities.php | 228 ++++++++++++++-------------- lang/de/settings.php | 275 +++++++++++++++++----------------- lang/de_informal/entities.php | 8 +- lang/de_informal/settings.php | 12 +- lang/ko/editor.php | 2 +- lang/ko/errors.php | 4 +- lang/pt/common.php | 4 +- lang/pt/editor.php | 4 +- lang/pt/entities.php | 22 +-- 13 files changed, 286 insertions(+), 287 deletions(-) diff --git a/lang/da/editor.php b/lang/da/editor.php index f135f06a9..628e1319e 100644 --- a/lang/da/editor.php +++ b/lang/da/editor.php @@ -36,7 +36,7 @@ return [ 'paragraph' => 'Paragraf', 'blockquote' => 'Citat', 'inline_code' => 'Inline kode', - 'callouts' => 'Callouts', + 'callouts' => 'Tekstfelter', 'callout_information' => 'Information', 'callout_success' => 'Succes', 'callout_warning' => 'Advarsel', diff --git a/lang/da/entities.php b/lang/da/entities.php index 23cc94626..ecda8a8cf 100644 --- a/lang/da/entities.php +++ b/lang/da/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'Dette vil slette den uploadede ZIP-fil og kan ikke fortrydes.', 'import_errors' => 'Importfejl', 'import_errors_desc' => 'Følgende fejl opstod under importforsøget:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Naviger blandt siderne', + 'breadcrumb_siblings_for_chapter' => 'Gå til næste eller forrige kapitel', + 'breadcrumb_siblings_for_book' => 'Gennemse søskende til bogen', + 'breadcrumb_siblings_for_bookshelf' => 'Gennemse undermapper til hylden', // Permissions and restrictions 'permissions' => 'Rettigheder', diff --git a/lang/da/errors.php b/lang/da/errors.php index eeb96f59b..62a275547 100644 --- a/lang/da/errors.php +++ b/lang/da/errors.php @@ -125,7 +125,7 @@ return [ 'api_incorrect_token_secret' => 'Hemmeligheden leveret til det givne anvendte API-token er forkert', 'api_user_no_api_permission' => 'Ejeren af den brugte API token har ikke adgang til at foretage API-kald', 'api_user_token_expired' => 'Den brugte godkendelsestoken er udløbet', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Der tillades kun GET-anmodninger, når API\'et bruges med cookie-baseret godkendelse', // Settings & Maintenance 'maintenance_test_email_failure' => 'Følgende fejl opstod under afsendelse af testemail:', diff --git a/lang/da/settings.php b/lang/da/settings.php index cd869c62f..1edf10d0e 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -104,7 +104,7 @@ return [ 'sort_rule_op_chapters_first' => 'Kapitler først', 'sort_rule_op_chapters_last' => 'De sidste kapitler', 'sorting_page_limits' => 'Visningsgrænser pr. side', - 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using a multiple of 6 is recommended.', + 'sorting_page_limits_desc' => 'Indstil, hvor mange poster der skal vises pr. side i de forskellige lister i systemet. Normalt giver et lavere antal bedre ydeevne, mens et højere antal undgår, at man skal klikke sig igennem flere sider. Det anbefales at vælge et tal, der er et multiplum af 6.', // Maintenance settings 'maint' => 'Vedligeholdelse', diff --git a/lang/de/entities.php b/lang/de/entities.php index 94c327b7e..52f9b7aca 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -23,175 +23,175 @@ return [ 'meta_updated' => 'Zuletzt aktualisiert: :timeLength', 'meta_updated_name' => 'Zuletzt aktualisiert: :timeLength von :user', 'meta_owned_name' => 'Im Besitz von :user', - 'meta_reference_count' => 'Referenziert von :count Element|Referenziert von :count Elementen', + 'meta_reference_count' => 'Verwiesen von :count Element|verwiesen von :count Elementen', 'entity_select' => 'Eintrag auswählen', - 'entity_select_lack_permission' => 'Sie haben nicht die benötigte Berechtigung, um dieses Element auszuwählen', + 'entity_select_lack_permission' => 'Sie verfügen nicht über die erforderlichen Berechtigungen, um dieses Element auszuwählen', 'images' => 'Bilder', - 'my_recent_drafts' => 'Meine kürzlichen Entwürfe', - 'my_recently_viewed' => 'Kürzlich von mir angesehen', + 'my_recent_drafts' => 'Meine letzten Entwürfe', + 'my_recently_viewed' => 'Meine zuletzt angesehenen', 'my_most_viewed_favourites' => 'Meine meistgesehenen Favoriten', 'my_favourites' => 'Meine Favoriten', - 'no_pages_viewed' => 'Sie haben bisher keine Seiten angesehen', - 'no_pages_recently_created' => 'Sie haben bisher keine Seiten angelegt', - 'no_pages_recently_updated' => 'Sie haben bisher keine Seiten aktualisiert', + 'no_pages_viewed' => 'Sie haben noch keine Seiten aufgerufen', + 'no_pages_recently_created' => 'Es wurden in letzter Zeit keine Seiten erstellt', + 'no_pages_recently_updated' => 'Es wurden in letzter Zeit keine Seiten aktualisiert', 'export' => 'Exportieren', - 'export_html' => 'HTML-Datei', + 'export_html' => 'Eingebettete Webdatei', 'export_pdf' => 'PDF-Datei', - 'export_text' => 'Textdatei', + 'export_text' => 'Klartextdatei', 'export_md' => 'Markdown-Datei', - 'export_zip' => 'Portable ZIP', + 'export_zip' => 'Portables ZIP', 'default_template' => 'Standard-Seitenvorlage', - 'default_template_explain' => 'Bestimmen Sie eine Seitenvorlage, die als Standardinhalt für alle Seiten verwendet wird, die innerhalb dieses Elements erstellt werden. Beachten Sie, dass dies nur dann verwendet wird, wenn der Ersteller der Seite Lesezugriff auf die ausgewählte Vorlagen-Seite hat.', + 'default_template_explain' => 'Weisen Sie eine Seitenvorlage zu, die als Standardinhalt für alle innerhalb dieses Elements erstellten Seiten verwendet wird. Beachten Sie, dass diese nur verwendet wird, wenn der Ersteller der Seite über Anzeigerechte für die ausgewählte Vorlagenseite verfügt.', 'default_template_select' => 'Wählen Sie eine Seitenvorlage', - 'import' => 'Import', - 'import_validate' => 'Import validieren', - 'import_desc' => 'Importieren Sie Bücher, Kapitel & Seiten mit einem "Portable Zip-Export" von der gleichen oder einer anderen Instanz. Wählen Sie eine ZIP-Datei, um fortzufahren. Nachdem die Datei hochgeladen und bestätigt wurde, können Sie den Import in der nächsten Ansicht konfigurieren und bestätigen.', - 'import_zip_select' => 'ZIP-Datei zum Hochladen auswählen', - 'import_zip_validation_errors' => 'Fehler bei der Validierung der angegebenen ZIP-Datei:', + 'import' => 'Importieren', + 'import_validate' => 'Import bestätigen', + 'import_desc' => 'Importieren Sie Bücher, Kapitel und Seiten mithilfe eines portablen ZIP-Exports aus derselben oder einer anderen Instanz. Wählen Sie eine ZIP-Datei aus, um fortzufahren. Nachdem die Datei hochgeladen und überprüft wurde, können Sie den Import in der nächsten Ansicht konfigurieren und bestätigen.', + 'import_zip_select' => 'Wähle eine ZIP-Datei zum Hochladen aus', + 'import_zip_validation_errors' => 'Bei der Überprüfung der bereitgestellten ZIP-Datei wurden Fehler festgestellt:', 'import_pending' => 'Ausstehende Importe', 'import_pending_none' => 'Es wurden keine Importe gestartet.', 'import_continue' => 'Import fortsetzen', - 'import_continue_desc' => 'Überprüfen Sie den Inhalt, der aus der hochgeladenen ZIP-Datei importiert werden soll. Führen Sie den Import aus, um dessen Inhalt zu diesem System hinzuzufügen. Die hochgeladene ZIP-Importdatei wird bei erfolgreichem Import automatisch entfernt.', - 'import_details' => 'Einzelheiten zum Import', - 'import_run' => 'Import starten', + 'import_continue_desc' => 'Überprüfen Sie den Inhalt, der aus der hochgeladenen ZIP-Datei importiert werden soll. Wenn Sie bereit sind, starten Sie den Import, um den Inhalt in dieses System zu übernehmen. Die hochgeladene ZIP-Importdatei wird nach erfolgreichem Import automatisch gelöscht.', + 'import_details' => 'Importdetails', + 'import_run' => 'Import ausführen', 'import_size' => ':size Import ZIP Größe', 'import_uploaded_at' => 'Hochgeladen :relativeTime', 'import_uploaded_by' => 'Hochgeladen von', - 'import_location' => 'Import Ort', - 'import_location_desc' => 'Wählen Sie einen Zielort für Ihren importierten Inhalt. Sie benötigen die entsprechenden Berechtigungen, um innerhalb des gewünschten Standortes zu erstellen.', - 'import_delete_confirm' => 'Sind Sie sicher, dass Sie diesen Import löschen möchten?', - 'import_delete_desc' => 'Dies löscht die hochgeladene ZIP-Datei und kann nicht rückgängig gemacht werden.', + 'import_location' => 'Importort', + 'import_location_desc' => 'Wählen Sie einen Zielspeicherort für Ihre importierten Inhalte aus. Sie benötigen die entsprechenden Berechtigungen, um an dem von Ihnen gewählten Speicherort Inhalte zu erstellen.', + 'import_delete_confirm' => 'Möchten Sie diesen Import wirklich löschen?', + 'import_delete_desc' => 'Dadurch wird die hochgeladene ZIP-Importdatei gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.', 'import_errors' => 'Importfehler', - 'import_errors_desc' => 'Die folgenden Fehler sind während des Importversuchs aufgetreten:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigiere in Büchern', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'import_errors_desc' => 'Beim Importversuch sind folgende Fehler aufgetreten:', + 'breadcrumb_siblings_for_page' => 'Durch die untergeordneten Elemente der Seite navigieren', + 'breadcrumb_siblings_for_chapter' => 'Durch die Unterelemente des Kapitels navigieren', + 'breadcrumb_siblings_for_book' => 'Durch die Unterordner des Buches navigieren', + 'breadcrumb_siblings_for_bookshelf' => 'Durch die untergeordneten Elemente des Regals navigieren', // Permissions and restrictions 'permissions' => 'Berechtigungen', - 'permissions_desc' => 'Legen Sie hier Berechtigungen fest, um die Standardberechtigungen von Benutzerrollen zu überschreiben.', + 'permissions_desc' => 'Legen Sie hier Berechtigungen fest, um die durch Benutzerrollen vorgegebenen Standardberechtigungen zu überschreiben.', 'permissions_book_cascade' => 'In Büchern festgelegte Berechtigungen werden automatisch in untergeordnete Kapitel und Seiten kaskadiert, es sei denn, sie haben eigene Berechtigungen definiert.', - 'permissions_chapter_cascade' => 'In Kapiteln festgelegte Berechtigungen werden automatisch in untergeordnete Seiten kaskadiert, es sei denn, sie haben eigene Berechtigungen definiert.', + 'permissions_chapter_cascade' => 'Die für Kapitel festgelegten Berechtigungen werden automatisch auf untergeordnete Seiten übertragen, sofern für diese keine eigenen Berechtigungen definiert sind.', 'permissions_save' => 'Berechtigungen speichern', 'permissions_owner' => 'Besitzer', 'permissions_role_everyone_else' => 'Alle anderen', - 'permissions_role_everyone_else_desc' => 'Berechtigungen für alle Rollen setzen, die nicht explizit überschrieben wurden.', - 'permissions_role_override' => 'Berechtigungen für Rolle überschreiben', - 'permissions_inherit_defaults' => 'Standardeinstellungen vererben', + 'permissions_role_everyone_else_desc' => 'Berechtigungen für alle Rollen festlegen, die nicht ausdrücklich überschrieben wurden.', + 'permissions_role_override' => 'Berechtigungen für eine Rolle überschreiben', + 'permissions_inherit_defaults' => 'Standardwerte übernehmen', // Search 'search_results' => 'Suchergebnisse', - 'search_total_results_found' => ':count Ergebnis gefunden|:count Ergebnisse gesamt', - 'search_clear' => 'Filter löschen', - 'search_no_pages' => 'Keine Seiten gefunden', + 'search_total_results_found' => ':count Ergebnisse gefunden|:count insgesamt gefundene Ergebnisse', + 'search_clear' => 'Suche löschen', + 'search_no_pages' => 'Es wurden keine Seiten gefunden, die dieser Suche entsprechen', 'search_for_term' => 'Nach :term suchen', 'search_more' => 'Mehr Ergebnisse', 'search_advanced' => 'Erweiterte Suche', 'search_terms' => 'Suchbegriffe', 'search_content_type' => 'Inhaltstyp', - 'search_exact_matches' => 'Exakte Treffer', + 'search_exact_matches' => 'Genau übereinstimmende Treffer', 'search_tags' => 'Schlagwort-Suchen', 'search_options' => 'Optionen', - 'search_viewed_by_me' => 'Schon von mir angesehen', - 'search_not_viewed_by_me' => 'Noch nicht von mir angesehen', + 'search_viewed_by_me' => 'Von mir angesehen', + 'search_not_viewed_by_me' => 'Von mir nicht angesehen', 'search_permissions_set' => 'Berechtigungen gesetzt', 'search_created_by_me' => 'Von mir erstellt', 'search_updated_by_me' => 'Von mir aktualisiert', 'search_owned_by_me' => 'In meinem Besitz', 'search_date_options' => 'Datums Optionen', - 'search_updated_before' => 'Aktualisiert vor', + 'search_updated_before' => 'Zuletzt aktualisiert am', 'search_updated_after' => 'Aktualisiert nach', 'search_created_before' => 'Erstellt vor', 'search_created_after' => 'Erstellt nach', - 'search_set_date' => 'Datum auswählen', + 'search_set_date' => 'Datum festlegen', 'search_update' => 'Suche aktualisieren', // Shelves 'shelf' => 'Regal', 'shelves' => 'Regale', 'x_shelves' => ':count Regal|:count Regale', - 'shelves_empty' => 'Es wurden noch keine Regale angelegt', - 'shelves_create' => 'Erzeuge ein Regal', + 'shelves_empty' => 'Es wurden keine Regale angelegt', + 'shelves_create' => 'Neues Regal erstellen', 'shelves_popular' => 'Beliebte Regale', 'shelves_new' => 'Kürzlich erstellte Regale', 'shelves_new_action' => 'Neues Regal', 'shelves_popular_empty' => 'Die beliebtesten Regale werden hier angezeigt.', - 'shelves_new_empty' => 'Die neusten Regale werden hier angezeigt.', + 'shelves_new_empty' => 'Hier werden die zuletzt erstellten Regale angezeigt.', 'shelves_save' => 'Regal speichern', 'shelves_books' => 'Bücher in diesem Regal', 'shelves_add_books' => 'Buch zu diesem Regal hinzufügen', - 'shelves_drag_books' => 'Ziehen Sie Bücher nach unten, um sie diesem Regal hinzuzufügen', + 'shelves_drag_books' => 'Ziehe die unten stehenden Bücher per Drag-and-drop auf dieses Regal, um sie hinzuzufügen', 'shelves_empty_contents' => 'Diesem Regal sind keine Bücher zugewiesen', - 'shelves_edit_and_assign' => 'Regal bearbeiten um Bücher hinzuzufügen', + 'shelves_edit_and_assign' => 'Regal bearbeiten, um Bücher zuzuordnen', 'shelves_edit_named' => 'Regal :name bearbeiten', 'shelves_edit' => 'Regal bearbeiten', 'shelves_delete' => 'Regal löschen', 'shelves_delete_named' => 'Regal :name löschen', - 'shelves_delete_explain' => "Dadurch wird das Regal mit dem Namen ':name' gelöscht. Die darin enthaltenen Bücher werden nicht gelöscht.", - 'shelves_delete_confirmation' => 'Sind Sie sicher, dass Sie dieses Regal löschen möchten?', - 'shelves_permissions' => 'Regalberechtigungen', - 'shelves_permissions_updated' => 'Regalberechtigungen aktualisiert', - 'shelves_permissions_active' => 'Regalberechtigungen aktiv', - 'shelves_permissions_cascade_warning' => 'Berechtigungen für Regale werden nicht automatisch auf die enthaltenen Bücher übertragen. Das liegt daran, dass ein Buch in mehreren Regalen vorhanden sein kann. Berechtigungen können jedoch auf untergeordnete Bücher kopiert werden, indem Sie die unten stehende Option verwenden.', - 'shelves_permissions_create' => 'Regalerstellungsberechtigungen werden nur zum Kopieren von Berechtigungen für untergeordnete Bücher mit der folgenden Aktion verwendet. Sie kontrollieren nicht die Fähigkeit, Bücher zu erstellen.', - 'shelves_copy_permissions_to_books' => 'Kopiere die Berechtigungen zum Buch', - 'shelves_copy_permissions' => 'Berechtigungen kopieren', - 'shelves_copy_permissions_explain' => 'Dadurch werden die aktuellen Berechtigungseinstellungen dieses Regals auf alle darin enthaltenen Bücher angewendet. Vergewissern Sie sich vor der Aktivierung, dass alle Änderungen an den Berechtigungen für dieses Regal gespeichert wurden.', - 'shelves_copy_permission_success' => 'Regalberechtigungen auf :count Bücher kopiert', + 'shelves_delete_explain' => "Dadurch wird das Regal mit dem Namen „:name“ gelöscht. Die darin enthaltenen Bücher werden nicht gelöscht.", + 'shelves_delete_confirmation' => 'Möchtest du dieses Regal wirklich löschen?', + 'shelves_permissions' => 'Berechtigungen für Regale', + 'shelves_permissions_updated' => 'Berechtigungen für Regale aktualisiert', + 'shelves_permissions_active' => 'Regal Berechtigungen aktiv', + 'shelves_permissions_cascade_warning' => 'Berechtigungen für Regale werden nicht automatisch auf die darin enthaltenen Bücher übertragen. Das liegt daran, dass ein Buch in mehreren Regalen stehen kann. Berechtigungen können jedoch mithilfe der unten aufgeführten Option auf untergeordnete Bücher übertragen werden.', + 'shelves_permissions_create' => 'Berechtigungen zum Erstellen von Regalen werden ausschließlich dazu verwendet, Berechtigungen mithilfe der unten beschriebenen Aktion auf untergeordnete Bücher zu kopieren. Sie haben keinen Einfluss auf die Möglichkeit, Bücher zu erstellen.', + 'shelves_copy_permissions_to_books' => 'Kopiere die Berechtigungen zu den Büchern', + 'shelves_copy_permissions' => 'Kopierrechte', + 'shelves_copy_permissions_explain' => 'Dadurch werden die aktuellen Berechtigungseinstellungen dieses Regals auf alle darin enthaltenen Bücher angewendet. Stellen Sie vor der Aktivierung sicher, dass alle Änderungen an den Berechtigungen dieses Regals gespeichert wurden.', + 'shelves_copy_permission_success' => 'Regal Berechtigungen auf :count Bücher kopiert', // Books 'book' => 'Buch', 'books' => 'Bücher', 'x_books' => ':count Buch|:count Bücher', - 'books_empty' => 'Keine Bücher vorhanden', + 'books_empty' => 'Es wurden keine Bücher erstellt', 'books_popular' => 'Beliebte Bücher', - 'books_recent' => 'Kürzlich angesehene Bücher', + 'books_recent' => 'Aktuelle Bücher', 'books_new' => 'Neue Bücher', 'books_new_action' => 'Neues Buch', 'books_popular_empty' => 'Die beliebtesten Bücher werden hier angezeigt.', - 'books_new_empty' => 'Die neusten Bücher werden hier angezeigt.', + 'books_new_empty' => 'Hier werden die zuletzt erstellten Bücher angezeigt.', 'books_create' => 'Neues Buch erstellen', 'books_delete' => 'Buch löschen', 'books_delete_named' => 'Buch ":bookName" löschen', - 'books_delete_explain' => 'Das Buch ":bookName" wird gelöscht und alle zugehörigen Kapitel und Seiten entfernt.', - 'books_delete_confirmation' => 'Sind Sie sicher, dass Sie dieses Buch löschen möchten?', + 'books_delete_explain' => 'Dadurch wird das Buch mit dem Namen „:bookName“ gelöscht. Alle Seiten und Kapitel werden entfernt. Das Buch ":bookName" wird gelöscht und alle zugehörigen Kapitel und Seiten entfernt.', + 'books_delete_confirmation' => 'Möchtest du dieses Buch wirklich löschen?', 'books_edit' => 'Buch bearbeiten', 'books_edit_named' => 'Buch ":bookName" bearbeiten', 'books_form_book_name' => 'Name des Buches', 'books_save' => 'Buch speichern', - 'books_permissions' => 'Buch-Berechtigungen', - 'books_permissions_updated' => 'Buch-Berechtigungen aktualisiert', - 'books_empty_contents' => 'Es sind noch keine Seiten oder Kapitel zu diesem Buch hinzugefügt worden.', - 'books_empty_create_page' => 'Neue Seite anlegen', + 'books_permissions' => 'Buch Berechtigungen', + 'books_permissions_updated' => 'Bücherberechtigungen aktualisiert', + 'books_empty_contents' => 'Für dieses Buch wurden noch keine Seiten oder Kapitel angelegt.', + 'books_empty_create_page' => 'Eine neue Seite erstellen', 'books_empty_sort_current_book' => 'Aktuelles Buch sortieren', - 'books_empty_add_chapter' => 'Neues Kapitel hinzufügen', - 'books_permissions_active' => 'Buch-Berechtigungen aktiv', - 'books_search_this' => 'Dieses Buch durchsuchen', + 'books_empty_add_chapter' => 'Ein Kapitel hinzufügen', + 'books_permissions_active' => 'Bücherberechtigungen aktiv', + 'books_search_this' => 'In diesem Buch suchen', 'books_navigation' => 'Buchnavigation', - 'books_sort' => 'Buchinhalte sortieren', - 'books_sort_desc' => 'Kapitel und Seiten innerhalb eines Buches verschieben, um dessen Inhalt zu reorganisieren. Andere Bücher können hinzugefügt werden, was das Verschieben von Kapiteln und Seiten zwischen Büchern erleichtert. Optional kann eine automatische Sortierregel erstellt werden, um den Inhalt dieses Buches nach Änderungen automatisch zu sortieren.', - 'books_sort_auto_sort' => 'Auto-Sortieroption', + 'books_sort' => 'Buchinhalt sortieren', + 'books_sort_desc' => 'Verschieben Sie Kapitel und Seiten innerhalb eines Buches, um dessen Inhalt neu zu ordnen. Es können weitere Bücher hinzugefügt werden, wodurch Kapitel und Seiten problemlos zwischen den Büchern verschoben werden können. Optional kann eine automatische Sortierregel festgelegt werden, um den Inhalt dieses Buches bei Änderungen automatisch zu sortieren.', + 'books_sort_auto_sort' => 'Automatische Sortierfunktionsoption', 'books_sort_auto_sort_active' => 'Automatische Sortierung aktiv: :sortName', 'books_sort_named' => 'Buch ":bookName" sortieren', 'books_sort_name' => 'Sortieren nach Namen', 'books_sort_created' => 'Sortieren nach Erstellungsdatum', 'books_sort_updated' => 'Sortieren nach Aktualisierungsdatum', - 'books_sort_chapters_first' => 'Kapitel zuerst', - 'books_sort_chapters_last' => 'Kapitel zuletzt', + 'books_sort_chapters_first' => 'Erstes Kapitel zuerst', + 'books_sort_chapters_last' => 'Letztes Kapitel zuletzt', 'books_sort_show_other' => 'Andere Bücher anzeigen', 'books_sort_save' => 'Neue Reihenfolge speichern', - 'books_sort_show_other_desc' => 'Füge hier weitere Bücher hinzu, um sie in die Sortierung einzubinden und ermögliche so eine einfache und übergreifende Reorganisation.', + 'books_sort_show_other_desc' => 'Fügen Sie hier weitere Bücher hinzu, um sie in die Sortierung einzubeziehen, und ermöglichen Sie so eine einfache Neuanordnung über mehrere Bücher hinweg.', 'books_sort_move_up' => 'Nach oben bewegen', 'books_sort_move_down' => 'Nach unten bewegen', - 'books_sort_move_prev_book' => 'Zum vorherigen Buch verschieben', - 'books_sort_move_next_book' => 'Zum nächsten Buch verschieben', - 'books_sort_move_prev_chapter' => 'In das vorherige Kapitel verschieben', - 'books_sort_move_next_chapter' => 'In nächstes Kapitel verschieben', - 'books_sort_move_book_start' => 'Zum Buchbeginn verschieben', - 'books_sort_move_book_end' => 'Zum Ende des Buches verschieben', - 'books_sort_move_before_chapter' => 'Vor Kapitel verschieben', - 'books_sort_move_after_chapter' => 'Nach Kapitel verschieben', + 'books_sort_move_prev_book' => 'Zum vorherigen Buch wechseln', + 'books_sort_move_next_book' => 'Zum nächsten Buch wechseln', + 'books_sort_move_prev_chapter' => 'Zum vorherigen Kapitel wechseln', + 'books_sort_move_next_chapter' => 'In das nächste Kapitel wechseln', + 'books_sort_move_book_start' => 'Zum Anfang des Buches springen', + 'books_sort_move_book_end' => 'Zum Ende des Buches springen', + 'books_sort_move_before_chapter' => 'Zum vorherigen Kapitel springen', + 'books_sort_move_after_chapter' => 'Weiter zum nächsten Kapitel', 'books_copy' => 'Buch kopieren', 'books_copy_success' => 'Das Buch wurde erfolgreich kopiert', @@ -201,11 +201,11 @@ return [ 'x_chapters' => ':count Kapitel', 'chapters_popular' => 'Beliebte Kapitel', 'chapters_new' => 'Neues Kapitel', - 'chapters_create' => 'Neues Kapitel anlegen', - 'chapters_delete' => 'Kapitel entfernen', + 'chapters_create' => 'Neues Kapitel erstellen', + 'chapters_delete' => 'Kapitel löschen', 'chapters_delete_named' => 'Kapitel ":chapterName" entfernen', - 'chapters_delete_explain' => 'Dies löscht das Kapitel mit dem Namen \':chapterName\'. Alle Seiten, die innerhalb dieses Kapitels existieren, werden ebenfalls gelöscht.', - 'chapters_delete_confirm' => 'Sind Sie sicher, dass Sie dieses Kapitel löschen möchten?', + 'chapters_delete_explain' => 'Dadurch wird das Kapitel mit dem Namen „:chapterName“ gelöscht. Alle Seiten, die zu diesem Kapitel gehören, werden ebenfalls gelöscht.', + 'chapters_delete_confirm' => 'Möchtest du dieses Kapitel wirklich löschen?', 'chapters_edit' => 'Kapitel bearbeiten', 'chapters_edit_named' => 'Kapitel ":chapterName" bearbeiten', 'chapters_save' => 'Kapitel speichern', @@ -213,10 +213,10 @@ return [ 'chapters_move_named' => 'Kapitel ":chapterName" verschieben', 'chapters_copy' => 'Kapitel kopieren', 'chapters_copy_success' => 'Kapitel erfolgreich kopiert', - 'chapters_permissions' => 'Kapitel-Berechtigungen', - 'chapters_empty' => 'Aktuell sind keine Kapitel diesem Buch hinzugefügt worden.', - 'chapters_permissions_active' => 'Kapitel-Berechtigungen aktiv', - 'chapters_permissions_success' => 'Kapitel-Berechtigungenen aktualisisert', + 'chapters_permissions' => 'Kapitel Berechtigungen', + 'chapters_empty' => 'Dieses Kapitel enthält derzeit keine Seiten.', + 'chapters_permissions_active' => 'Kapitel Berechtigungen aktiv', + 'chapters_permissions_success' => 'Kapitel Berechtigungen aktualisiert', 'chapters_search_this' => 'Dieses Kapitel durchsuchen', 'chapter_sort_book' => 'Buch sortieren', @@ -230,22 +230,22 @@ return [ 'pages_navigation' => 'Seitennavigation', 'pages_delete' => 'Seite löschen', 'pages_delete_named' => 'Seite ":pageName" löschen', - 'pages_delete_draft_named' => 'Seitenentwurf von ":pageName" löschen', - 'pages_delete_draft' => 'Seitenentwurf löschen', + 'pages_delete_draft_named' => 'Entwurf von ":pageName" löschen', + 'pages_delete_draft' => 'Entwurf löschen', 'pages_delete_success' => 'Seite gelöscht', - 'pages_delete_draft_success' => 'Seitenentwurf gelöscht', - 'pages_delete_warning_template' => 'Diese Seite wird aktiv als Standardvorlage für Bücher oder Kapitel verwendet. In diesen Büchern oder Kapiteln wird nach dem Löschen dieser Seite keine Standardvorlage mehr zugewiesen sein.', - 'pages_delete_confirm' => 'Sind Sie sicher, dass Sie diese Seite löschen möchen?', - 'pages_delete_draft_confirm' => 'Sind Sie sicher, dass Sie diesen Seitenentwurf löschen möchten?', + 'pages_delete_draft_success' => 'Entwurf der Seite gelöscht', + 'pages_delete_warning_template' => 'Diese Seite wird derzeit als Standardvorlage für Bücher oder Kapitel verwendet. Nach dem Löschen dieser Seite wird diesen Büchern oder Kapiteln keine Standardvorlage mehr zugewiesen.', + 'pages_delete_confirm' => 'Möchtest du diese Seite wirklich löschen?', + 'pages_delete_draft_confirm' => 'Möchtest du diese Entwurfsseite wirklich löschen?', 'pages_editing_named' => 'Seite ":pageName" bearbeiten', 'pages_edit_draft_options' => 'Entwurfsoptionen', 'pages_edit_save_draft' => 'Entwurf speichern', 'pages_edit_draft' => 'Seitenentwurf bearbeiten', - 'pages_editing_draft' => 'Seitenentwurf bearbeiten', + 'pages_editing_draft' => 'Entwurf bearbeiten', 'pages_editing_page' => 'Seite bearbeiten', - 'pages_edit_draft_save_at' => 'Entwurf gesichert um ', + 'pages_edit_draft_save_at' => 'Entwurf gespeichert unter ', 'pages_edit_delete_draft' => 'Entwurf löschen', - 'pages_edit_delete_draft_confirm' => 'Sind Sie sicher, dass Sie Ihren Entwurf löschen möchten? Alle Ihre Änderungen seit dem letzten vollständigen Speichern gehen verloren und der Editor wird mit dem letzten Speicherzustand aktualisiert, der kein Entwurf ist.', + 'pages_edit_delete_draft_confirm' => 'Möchten Sie die Änderungen an Ihrem Seitenentwurf wirklich löschen? Alle Ihre Änderungen seit der letzten vollständigen Speicherung gehen verloren, und der Editor wird mit dem letzten gespeicherten Stand der Seite, ohne Entwurf, aktualisiert.', 'pages_edit_discard_draft' => 'Entwurf verwerfen', 'pages_edit_switch_to_markdown' => 'Zum Markdown-Editor wechseln', 'pages_edit_switch_to_markdown_clean' => '(Gesäuberter Inhalt)', @@ -254,18 +254,18 @@ return [ 'pages_edit_switch_to_new_wysiwyg' => 'Zum neuen WYSIWYG wechseln', 'pages_edit_switch_to_new_wysiwyg_desc' => '(Im Beta-Test)', 'pages_edit_set_changelog' => 'Änderungsprotokoll hinzufügen', - 'pages_edit_enter_changelog_desc' => 'Bitte geben Sie eine kurze Zusammenfassung Ihrer Änderungen ein', + 'pages_edit_enter_changelog_desc' => 'Geben Sie eine kurze Beschreibung der vorgenommenen Änderungen ein', 'pages_edit_enter_changelog' => 'Änderungsprotokoll eingeben', 'pages_editor_switch_title' => 'Editor wechseln', - 'pages_editor_switch_are_you_sure' => 'Sind Sie sicher, dass Sie den Editor für diese Seite ändern möchten?', - 'pages_editor_switch_consider_following' => 'Betrachten Sie folgendes beim Ändern von Editoren:', - 'pages_editor_switch_consideration_a' => 'Einmal gespeichert, wird die neue Editoroption von zukünftigen Editoren verwendet, einschließlich derjenigen, die nicht in der Lage sind, den Editortyp selbst zu ändern.', - 'pages_editor_switch_consideration_b' => 'Dies kann unter bestimmten Umständen zu einem Verlust von Details und Quellcode führen.', - 'pages_editor_switch_consideration_c' => 'Änderungen des Tags oder Änderungsprotokolls, die seit dem letzten Speichern vorgenommen wurden, werden bei dieser Änderung nicht fortgesetzt.', + 'pages_editor_switch_are_you_sure' => 'Möchtest du den Editor dieser Seite wirklich ändern?', + 'pages_editor_switch_consider_following' => 'Beachten Sie beim Wechsel des Editors Folgendes:', + 'pages_editor_switch_consideration_a' => 'Sobald die Einstellung gespeichert ist, wird die neue Editor-Option von allen zukünftigen Editoren verwendet, auch von solchen, die den Editor-Typ möglicherweise nicht selbst ändern können.', + 'pages_editor_switch_consideration_b' => 'Dies kann unter bestimmten Umständen zu einem Verlust an Details und Syntax führen.', + 'pages_editor_switch_consideration_c' => 'Änderungen an Tags oder im Änderungsprotokoll, die seit dem letzten Speichern vorgenommen wurden, bleiben bei dieser Änderung nicht erhalten.', 'pages_save' => 'Seite speichern', 'pages_title' => 'Seitentitel', 'pages_name' => 'Seitenname', - 'pages_md_editor' => 'Redakteur', + 'pages_md_editor' => 'Editor', 'pages_md_preview' => 'Vorschau', 'pages_md_insert_image' => 'Bild einfügen', 'pages_md_insert_link' => 'Link zu einem Objekt einfügen', @@ -273,18 +273,18 @@ return [ 'pages_md_show_preview' => 'Vorschau anzeigen', 'pages_md_sync_scroll' => 'Vorschau synchronisieren', 'pages_md_plain_editor' => 'Einfacher Editor', - 'pages_drawing_unsaved' => 'Ungespeicherte Zeichnung gefunden', - 'pages_drawing_unsaved_confirm' => 'Es wurden ungespeicherte Zeichnungsdaten von einem früheren, fehlgeschlagenen Versuch, die Zeichnung zu speichern, gefunden. Möchten Sie diese ungespeicherte Zeichnung wiederherstellen und weiter bearbeiten?', - 'pages_not_in_chapter' => 'Seite ist in keinem Kapitel', + 'pages_drawing_unsaved' => 'Nicht gespeicherte Zeichnung gefunden', + 'pages_drawing_unsaved_confirm' => 'Es wurden nicht gespeicherte Zeichnungsdaten aus einem früheren fehlgeschlagenen Speichervorgang gefunden. Möchten Sie diese nicht gespeicherte Zeichnung wiederherstellen und weiter bearbeiten?', + 'pages_not_in_chapter' => 'Die Seite gehört zu keinem Kapitel', 'pages_move' => 'Seite verschieben', 'pages_copy' => 'Seite kopieren', - 'pages_copy_desination' => 'Ziel', + 'pages_copy_desination' => 'Kopierziel', 'pages_copy_success' => 'Seite erfolgreich kopiert', 'pages_permissions' => 'Seiten Berechtigungen', 'pages_permissions_success' => 'Seiten Berechtigungen aktualisiert', - 'pages_revision' => 'Version', - 'pages_revisions' => 'Seitenversionen', - 'pages_revisions_desc' => 'Alle vorherhigen Revisionen dieser Seite sind unten aufgelistet. Sie können zurückschauen, vergleichen und alte Seitenversionen wiederherstellen, wenn die Berechtigungen dies erlauben. Der vollständige Verlauf der Seite kann hier möglicherweise nicht vollständig wiedergegeben werden, da je nach Systemkonfiguration alte Revisionen automatisch hätten gelöscht werden können.', + 'pages_revision' => 'Überarbeitung', + 'pages_revisions' => 'Seitenüberarbeitungen', + 'pages_revisions_desc' => 'Nachfolgend sind alle bisherigen Überarbeitungen dieser Seite aufgeführt. Sofern Sie über die entsprechenden Berechtigungen verfügen, können Sie alte Seitenversionen einsehen, vergleichen und wiederherstellen. Möglicherweise ist der vollständige Verlauf der Seite hier nicht vollständig dargestellt, da alte Überarbeitungen je nach Systemkonfiguration automatisch gelöscht werden können.', 'pages_revisions_named' => 'Seitenversionen von ":pageName"', 'pages_revision_named' => 'Seitenversion von ":pageName"', 'pages_revision_restored_from' => 'Wiederhergestellt von #:id; :summary', diff --git a/lang/de/settings.php b/lang/de/settings.php index e57ac52c4..64af973a4 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -17,143 +17,142 @@ return [ 'app_features_security' => 'Funktionen & Sicherheit', 'app_name' => 'Anwendungsname', 'app_name_desc' => 'Dieser Name wird im Header und in E-Mails angezeigt.', - 'app_name_header' => 'Anwendungsname im Header anzeigen?', + 'app_name_header' => 'Namen in der Kopfzeile anzeigen', 'app_public_access' => 'Öffentlicher Zugriff', - 'app_public_access_desc' => 'Wenn Sie diese Option aktivieren, können Besucher, die nicht angemeldet sind, auf Inhalte in Ihrer BookStack-Instanz zugreifen.', - 'app_public_access_desc_guest' => 'Der Zugang für öffentliche Besucher kann über den Benutzer "Guest" gesteuert werden.', - 'app_public_access_toggle' => 'Öffentlichen Zugriff erlauben', - 'app_public_viewing' => 'Öffentliche Ansicht erlauben?', - 'app_secure_images' => 'Erhöhte Sicherheit für hochgeladene Bilder aktivieren?', + 'app_public_access_desc' => 'Wenn Sie diese Option aktivieren, können Besucher, die nicht angemeldet sind, auf Inhalte in Ihrer BookStack Instanz zugreifen.', + 'app_public_access_desc_guest' => 'Der Zugang für externe Besucher kann über den Benutzer „Gast“ geregelt werden.', + 'app_public_access_toggle' => 'Öffentlichen Zugriff gewähren', + 'app_public_viewing' => 'Öffentlich zugänglich machen?', + 'app_secure_images' => 'Sichereres Hochladen von Bildern', 'app_secure_images_toggle' => 'Höhere Sicherheit für Bild-Uploads aktivieren', - 'app_secure_images_desc' => 'Aus Leistungsgründen sind alle Bilder öffentlich sichtbar. Diese Option fügt zufällige, schwer zu erratende, Zeichenketten zu Bild-URLs hinzu. Stellen Sie sicher, dass Verzeichnisindizes deaktiviert sind, um einen einfachen Zugriff zu verhindern.', + 'app_secure_images_desc' => 'Aus Leistungsgründen sind alle Bilder öffentlich zugänglich. Diese Option fügt den Bild-URLs eine zufällige, schwer zu erratende Zeichenfolge vor. Stellen Sie sicher, dass Verzeichnisverzeichnisse nicht aktiviert sind, um einen einfachen Zugriff zu verhindern.', 'app_default_editor' => 'Standard-Seiten-Editor', - 'app_default_editor_desc' => 'Wählen Sie aus, welcher Editor standardmäßig beim Bearbeiten neuer Seiten verwendet wird. Dies kann auf einer Seitenebene überschrieben werden, wenn es die Berechtigungen erlauben.', + 'app_default_editor_desc' => 'Wählen Sie aus, welcher Editor standardmäßig beim Bearbeiten neuer Seiten verwendet werden soll. Diese Einstellung kann auf Seitenebene überschrieben werden, sofern die Berechtigungen dies zulassen.', 'app_custom_html' => 'Benutzerdefinierter HTML-Head-Inhalt', - 'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugefügt wird, wird am Ende der -Sektion jeder Seite eingefügt. Diese kann praktisch sein, um CSS-Styles anzupassen oder Analytics-Code hinzuzufügen.', - 'app_custom_html_disabled_notice' => 'Benutzerdefinierte HTML-Kopfzeileninhalte sind auf dieser Einstellungsseite deaktiviert, um sicherzustellen, dass alle Änderungen rückgängig gemacht werden können.', + 'app_custom_html_desc' => 'Alle hier hinzugefügten Inhalte werden am Ende des -Abschnitts jeder Seite eingefügt. Dies ist nützlich, um Stile zu überschreiben oder Analysecodes hinzuzufügen.', + 'app_custom_html_disabled_notice' => 'Auf dieser Einstellungsseite ist der benutzerdefinierte HTML-Head-Inhalt deaktiviert, um sicherzustellen, dass etwaige grundlegende Änderungen rückgängig gemacht werden können.', 'app_logo' => 'Anwendungslogo', - 'app_logo_desc' => 'Dies wird unter anderem in der Kopfzeile der Anwendung verwendet. Dieses Bild sollte 86px hoch sein. Große Bilder werden herunterskaliert.', + 'app_logo_desc' => 'Dieses Bild wird unter anderem in der Kopfzeile der Anwendung verwendet. Es sollte eine Höhe von 86 Pixel haben. Größere Bilder werden verkleinert.', 'app_icon' => 'Anwendungssymbol', - 'app_icon_desc' => 'Dieses Symbol wird für Browser-Registerkarten und Verknüpfungssymbole verwendet. Dies sollte ein 256px quadratisches PNG-Bild sein.', + 'app_icon_desc' => 'Dieses Symbol wird für Browser-Registerkarten und Verknüpfungssymbole verwendet. Es sollte sich um ein quadratisches PNG-Bild mit einer Größe von 256 px handeln.', 'app_homepage' => 'Startseite der Anwendung', - 'app_homepage_desc' => 'Wählen Sie eine Seite als Startseite aus, die statt der Standardansicht angezeigt werden soll. Seitenberechtigungen werden für die ausgewählten Seiten ignoriert.', + 'app_homepage_desc' => 'Wählen Sie eine Ansicht aus, die anstelle der Standardansicht auf der Startseite angezeigt werden soll. Seitenberechtigungen werden für ausgewählte Seiten ignoriert.', 'app_homepage_select' => 'Wählen Sie eine Seite aus', - 'app_footer_links' => 'Fußzeilen-Links', - 'app_footer_links_desc' => 'Fügen Sie Links hinzu, die innerhalb der Seitenfußzeile angezeigt werden. Diese werden am unteren Ende der meisten Seiten angezeigt, einschließlich derjenigen, die keine Anmeldung benötigen. Sie können die Bezeichnung "trans::" verwenden, um systemdefinierte Übersetzungen zu verwenden. Beispiel: Mit "trans::common.privacy_policy" wird der übersetzte Text "Privacy Policy" bereitgestellt und "trans::common.terms_of_service" liefert den übersetzten Text "Terms of Service".', + 'app_footer_links' => 'Links in der Fußzeile', + 'app_footer_links_desc' => 'Fügen Sie Links hinzu, die in der Fußzeile der Website angezeigt werden sollen. Diese werden am Ende der meisten Seiten angezeigt, auch auf solchen, für die keine Anmeldung erforderlich ist. Sie können die Bezeichnung „trans::“ verwenden, um vom System definierte Übersetzungen zu nutzen. Beispiel: Die Verwendung von „trans::common.privacy_policy“ liefert den übersetzten Text „Datenschutzerklärung“ und „trans::common.terms_of_service“ liefert den übersetzten Text „Nutzungsbedingungen“.', 'app_footer_links_label' => 'Link-Label', 'app_footer_links_url' => 'Link-URL', 'app_footer_links_add' => 'Fußzeilen-Link hinzufügen', 'app_disable_comments' => 'Kommentare deaktivieren', 'app_disable_comments_toggle' => 'Kommentare deaktivieren', - 'app_disable_comments_desc' => 'Deaktiviert Kommentare über alle Seiten in der Anwendung. Vorhandene Kommentare werden nicht angezeigt.', + 'app_disable_comments_desc' => 'Deaktiviert Kommentare auf allen Seiten der Anwendung. Bereits vorhandene Kommentare werden nicht angezeigt.', // Color settings 'color_scheme' => 'Farbschema der Anwendung', 'color_scheme_desc' => 'Lege die Farben, die in der Benutzeroberfläche verwendet werden, fest. Farben können separat für dunkle und helle Modi konfiguriert werden, um am besten zum Farbschema zu passen und die Lesbarkeit zu gewährleisten.', - 'ui_colors_desc' => 'Lege die primäre Farbe und die Standard-Linkfarbe der Anwendung fest. Die primäre Farbe wird hauptsächlich für Kopfzeilen, Buttons und Interface-Dekorationen verwendet. Die Standard-Linkfarbe wird für textbasierte Links und Aktionen sowohl innerhalb des geschriebenen Inhalts als auch in der Benutzeroberfläche verwendet.', + 'ui_colors_desc' => 'Legen Sie die Hauptfarbe der Anwendung und die Standardfarbe für Links fest. Die Hauptfarbe wird hauptsächlich für das Kopfzeilenbanner, Schaltflächen und Elemente zur Gestaltung der Benutzeroberfläche verwendet. Die Standardfarbe für Links wird für textbasierte Links und Aktionen verwendet, sowohl innerhalb von Textinhalten als auch in der Benutzeroberfläche der Anwendung.', 'app_color' => 'Primäre Farbe', - 'link_color' => 'Standard-Linkfarbe', + 'link_color' => 'Standardfarbe für Links', 'content_colors_desc' => 'Legt Farben für alle Elemente in der Seitenorganisationshierarchie fest. Die Auswahl von Farben mit einer ähnlichen Helligkeit wie die Standardfarben wird zur Lesbarkeit empfohlen.', - 'bookshelf_color' => 'Regalfarbe', + 'bookshelf_color' => 'Farbe des Regals', 'book_color' => 'Buchfarbe', - 'chapter_color' => 'Kapitelfarbe', + 'chapter_color' => 'Kapitel-Farbe', 'page_color' => 'Seitenfarbe', - 'page_draft_color' => 'Seitenentwurfsfarbe', + 'page_draft_color' => 'Farbe des Seitenentwurfs', // Registration Settings - 'reg_settings' => 'Registrierungseinstellungen', + 'reg_settings' => 'Registrierung', 'reg_enable' => 'Registrierung erlauben', 'reg_enable_toggle' => 'Registrierung erlauben', - 'reg_enable_desc' => 'Wenn die Registrierung erlaubt ist, kann sich der Benutzer als Anwendungsbenutzer anmelden. Bei der Registrierung erhält er eine einzige, voreingestellte Benutzerrolle.', - 'reg_default_role' => 'Standard-Benutzerrolle nach Registrierung', - 'reg_enable_external_warning' => 'Die obige Option wird ignoriert, während eine externe LDAP oder SAML Authentifizierung aktiv ist. Benutzerkonten für nicht existierende Mitglieder werden automatisch erzeugt, wenn die Authentifizierung gegen das verwendete externe System erfolgreich ist.', - 'reg_email_confirmation' => 'Bestätigung per E-Mail', - 'reg_email_confirmation_toggle' => 'Bestätigung per E-Mail erforderlich', + 'reg_enable_desc' => 'Wenn die Registrierung aktiviert ist, können sich Benutzer als Anwendungsbenutzer registrieren. Bei der Registrierung erhalten sie eine einzige Standardbenutzerrolle.', + 'reg_default_role' => 'Standardbenutzerrolle nach der Registrierung', + 'reg_enable_external_warning' => 'Die oben genannte Option wird ignoriert, solange die externe LDAP oder SAML Authentifizierung aktiv ist. Benutzerkonten für nicht vorhandene Mitglieder werden automatisch angelegt, wenn die Authentifizierung gegenüber dem verwendeten externen System erfolgreich ist.', + 'reg_email_confirmation' => 'E-Mail-Bestätigung', + 'reg_email_confirmation_toggle' => 'E-Mail-Bestätigung erforderlich', 'reg_confirm_email_desc' => 'Falls die Einschränkung für Domains genutzt wird, ist die Bestätigung per E-Mail zwingend erforderlich und der untenstehende Wert wird ignoriert.', 'reg_confirm_restrict_domain' => 'Registrierung auf bestimmte Domains einschränken', - 'reg_confirm_restrict_domain_desc' => 'Fügen Sie eine durch Komma getrennte Liste von Domains hinzu, auf die die Registrierung eingeschränkt werden soll. Benutzern wird eine E-Mail gesendet, um ihre E-Mail-Adresse zu bestätigen, bevor diese die Anwendung nutzen können. -Hinweis: Benutzer können ihre E-Mail-Adresse nach erfolgreicher Registrierung ändern.', + 'reg_confirm_restrict_domain_desc' => 'Geben Sie eine durch Kommas getrennte Liste der E-Mail-Domains ein, auf die Sie die Registrierung beschränken möchten. Die Nutzer erhalten eine E-Mail zur Bestätigung ihrer Adresse, bevor sie die Anwendung nutzen dürfen. Beachten Sie, dass die Nutzer ihre E-Mail-Adressen nach erfolgreicher Registrierung ändern können.', 'reg_confirm_restrict_domain_placeholder' => 'Keine Einschränkung gesetzt', // Sorting Settings - 'sorting' => 'Lists & Sorting', - 'sorting_book_default' => 'Default Book Sort Rule', + 'sorting' => 'Listen & Sortieren', + 'sorting_book_default' => 'Standardregel für die Sortierung von Büchern', 'sorting_book_default_desc' => 'Wählen Sie die Standard-Sortierregel aus, die auf neue Bücher angewendet werden soll. Dies wirkt sich nicht auf bestehende Bücher aus und kann pro Buch überschrieben werden.', 'sorting_rules' => 'Sortierregeln', 'sorting_rules_desc' => 'Dies sind vordefinierte Sortieraktionen, die auf Inhalte im System angewendet werden können.', - 'sort_rule_assigned_to_x_books' => ':count Buch zugewiesen|:count Büchern zugewiesen', + 'sort_rule_assigned_to_x_books' => 'Zugewiesen an: :count Buch|zugewiesen an: :count Bücher', 'sort_rule_create' => 'Sortierregel erstellen', 'sort_rule_edit' => 'Sortierregel bearbeiten', 'sort_rule_delete' => 'Sortierregel löschen', 'sort_rule_delete_desc' => 'Diese Sortierregel aus dem System entfernen. Bücher mit dieser Sortierung werden auf manuelle Sortierung zurückgesetzt.', - 'sort_rule_delete_warn_books' => 'Diese Sortierregel wird derzeit in :count Bücher(n) verwendet. Sind Sie sicher, dass Sie dies löschen möchten?', - 'sort_rule_delete_warn_default' => 'Diese Sortierregel wird derzeit als Standard für Bücher verwendet. Sind Sie sicher, dass Sie dies löschen möchten?', + 'sort_rule_delete_warn_books' => 'Diese Sortierregel wird derzeit auf :count Bücher angewendet. Möchten Sie diese wirklich löschen?', + 'sort_rule_delete_warn_default' => 'Diese Sortierregel wird derzeit Standard mäßig für Bücher verwendet. Möchten Sie sie wirklich löschen?', 'sort_rule_details' => 'Sortierregel-Details', - 'sort_rule_details_desc' => 'Legen Sie einen Namen für diese Sortierregel fest, der in Listen erscheint, wenn Benutzer eine Sortierung auswählen.', - 'sort_rule_operations' => 'Sortierungs-Aktionen', - 'sort_rule_operations_desc' => 'Konfigurieren Sie die durchzuführenden Sortieraktionen durch Verschieben von der Liste der verfügbaren Aktionen. Bei der Verwendung werden die Aktionen von oben nach unten angewendet. Alle hier vorgenommenen Änderungen werden beim Speichern auf alle zugewiesenen Bücher angewendet.', - 'sort_rule_available_operations' => 'Verfügbare Aktionen', - 'sort_rule_available_operations_empty' => 'Keine verbleibenden Aktionen', - 'sort_rule_configured_operations' => 'Konfigurierte Aktionen', - 'sort_rule_configured_operations_empty' => 'Aktionen aus der Liste "Verfügbare Operationen" ziehen/hinzufügen', - 'sort_rule_op_asc' => '(Aufst.)', - 'sort_rule_op_desc' => '(Abst.)', + 'sort_rule_details_desc' => 'Geben Sie einen Namen für diese Sortierregel ein, der in Listen angezeigt wird, wenn der Benutzer eine Sortieroption auswählt.', + 'sort_rule_operations' => 'Sortiervorgänge', + 'sort_rule_operations_desc' => 'Konfigurieren Sie die auszuführenden Sortieraktionen, indem Sie sie aus der Liste der verfügbaren Vorgänge verschieben. Bei der Ausführung werden die Vorgänge der Reihe nach von oben nach unten angewendet. Alle hier vorgenommenen Änderungen werden beim Speichern auf alle zugewiesenen Bücher angewendet.', + 'sort_rule_available_operations' => 'Verfügbare Funktionen', + 'sort_rule_available_operations_empty' => 'Es sind keine Vorgänge mehr vorhanden', + 'sort_rule_configured_operations' => 'Konfigurierte Vorgänge', + 'sort_rule_configured_operations_empty' => 'Vorgänge aus der Liste „Verfügbare Vorgänge“ per Drag-and-drop hinzufügen', + 'sort_rule_op_asc' => '(Aufsteigend)', + 'sort_rule_op_desc' => '(Absteigend)', 'sort_rule_op_name' => 'Name - Alphabetisch', 'sort_rule_op_name_numeric' => 'Name - Numerisch', 'sort_rule_op_created_date' => 'Erstellungsdatum', 'sort_rule_op_updated_date' => 'Aktualisierungsdatum', 'sort_rule_op_chapters_first' => 'Kapitel zuerst', 'sort_rule_op_chapters_last' => 'Kapitel zuletzt', - 'sorting_page_limits' => 'Per-Page Display Limits', - 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using a multiple of 6 is recommended.', + 'sorting_page_limits' => 'Anzeigebegrenzungen pro Seite', + 'sorting_page_limits_desc' => 'Legen Sie fest, wie viele Elemente pro Seite in den verschiedenen Listen des Systems angezeigt werden sollen. In der Regel ist eine geringere Anzahl leistungsfähiger, während eine höhere Anzahl das Blättern durch mehrere Seiten überflüssig macht. Es wird empfohlen, ein Vielfaches von 6 zu verwenden.', // Maintenance settings 'maint' => 'Wartung', 'maint_image_cleanup' => 'Bilder bereinigen', - 'maint_image_cleanup_desc' => 'Überprüft Seiten- und Versionsinhalte auf ungenutzte und mehrfach vorhandene Bilder. Erstellen Sie vor dem Start ein Backup Ihrer Datenbank und Bilder.', - 'maint_delete_images_only_in_revisions' => 'Lösche auch Bilder, die nur in alten Seitenüberarbeitungen vorhanden sind', + 'maint_image_cleanup_desc' => 'Überprüft den Inhalt der Seiten und Versionen, um festzustellen, welche Bilder und Zeichnungen derzeit verwendet werden und welche Bilder überflüssig sind. Stellen Sie sicher, dass Sie vor der Ausführung eine vollständige Sicherung der Datenbank und der Bilder erstellen.', + 'maint_delete_images_only_in_revisions' => 'Lösche auch Bilder, die nur in alten Seitenversionen vorhanden sind', 'maint_image_cleanup_run' => 'Reinigung starten', - 'maint_image_cleanup_warning' => ':count eventuell unbenutze Bilder wurden gefunden. Möchten Sie diese Bilder löschen?', - 'maint_image_cleanup_success' => ':count eventuell unbenutze Bilder wurden gefunden und gelöscht.', - 'maint_image_cleanup_nothing_found' => 'Keine unbenutzen Bilder gefunden. Nichts zu löschen!', - 'maint_send_test_email' => 'Eine Test-E-Mail versenden', - 'maint_send_test_email_desc' => 'Dies sendet eine Test-E-Mail an Ihre in Ihrem Profil angegebene E-Mail-Adresse.', + 'maint_image_cleanup_warning' => 'Es wurden :count möglicherweise nicht mehr benötigte Bilder gefunden. Möchten Sie diese Bilder wirklich löschen?', + 'maint_image_cleanup_success' => ':count möglicherweise nicht verwendete Bilder wurden gefunden und gelöscht!', + 'maint_image_cleanup_nothing_found' => 'Es wurden keine ungenutzten Bilder gefunden, nichts wurde gelöscht!', + 'maint_send_test_email' => 'Test-E-Mail senden', + 'maint_send_test_email_desc' => 'Dadurch wird eine Test-E-Mail an die in Ihrem Profil angegebene E-Mail-Adresse gesendet.', 'maint_send_test_email_run' => 'Test-E-Mail senden', - 'maint_send_test_email_success' => 'E-Mail wurde an :address gesendet', + 'maint_send_test_email_success' => 'E-Mail gesendet an :address', 'maint_send_test_email_mail_subject' => 'Test-E-Mail', - 'maint_send_test_email_mail_greeting' => 'E-Mail-Versand scheint zu funktionieren!', - 'maint_send_test_email_mail_text' => 'Glückwunsch! Da Sie diese E-Mail Benachrichtigung erhalten haben, scheinen Ihre E-Mail-Einstellungen korrekt konfiguriert zu sein.', - 'maint_recycle_bin_desc' => 'Gelöschte Regale, Bücher, Kapitel & Seiten werden in den Papierkorb verschoben, so dass sie wiederhergestellt oder dauerhaft gelöscht werden können. Ältere Gegenstände im Papierkorb können, in Abhängigkeit von der Systemkonfiguration, nach einer Weile automatisch entfernt werden.', + 'maint_send_test_email_mail_greeting' => 'Die E-Mail-Zustellung scheint zu funktionieren!', + 'maint_send_test_email_mail_text' => 'Herzlichen Glückwunsch! Da Sie diese E-Mail-Benachrichtigung erhalten haben, scheinen Ihre E-Mail-Einstellungen korrekt konfiguriert zu sein.', + 'maint_recycle_bin_desc' => 'Gelöschte Regale, Bücher, Kapitel und Seiten werden in den Papierkorb verschoben, sodass sie wiederhergestellt oder endgültig gelöscht werden können. Ältere Elemente im Papierkorb werden je nach Systemkonfiguration nach einer gewissen Zeit möglicherweise automatisch entfernt.', 'maint_recycle_bin_open' => 'Papierkorb öffnen', 'maint_regen_references' => 'Referenzen neu generieren', - 'maint_regen_references_desc' => 'Diese Aktion wird den Referenzindex innerhalb der Datenbank neu erstellen. Dies wird normalerweise automatisch ausgeführt, aber diese Aktion kann nützlich sein, um alte Inhalte oder Inhalte zu indizieren, die mittels inoffizieller Methoden hinzugefügt wurden.', - 'maint_regen_references_success' => 'Referenz-Index wurde neu generiert!', - 'maint_timeout_command_note' => 'Hinweis: Die Ausführung dieser Aktion kann einige Zeit in Anspruch nehmen, was in einigen Webumgebungen zu Timeout-Problemen führen kann. Alternativ kann diese Aktion auch mit einem Terminalbefehl ausgeführt werden.', + 'maint_regen_references_desc' => 'Diese Aktion erstellt den Index für die Querverweise innerhalb der Datenbank neu. Dies geschieht normalerweise automatisch, doch diese Aktion kann nützlich sein, um ältere Inhalte oder Inhalte, die über inoffizielle Methoden hinzugefügt wurden, zu indizieren.', + 'maint_regen_references_success' => 'Der Referenzindex wurde neu generiert!', + 'maint_timeout_command_note' => 'Hinweis: Die Ausführung dieser Aktion kann einige Zeit in Anspruch nehmen, was in manchen Webumgebungen zu Zeitüberschreitungsfehlern führen kann. Alternativ kann diese Aktion über einen Terminal Befehl ausgeführt werden.', // Recycle Bin 'recycle_bin' => 'Papierkorb', - 'recycle_bin_desc' => 'Hier können Sie gelöschte Elemente wiederherstellen oder sie dauerhaft aus dem System entfernen. Diese Liste ist nicht gefiltert, im Gegensatz zu ähnlichen Aktivitätslisten im System, wo Berechtigungsfilter angewendet werden.', - 'recycle_bin_deleted_item' => 'Gelöschtes Element', + 'recycle_bin_desc' => 'Hier können Sie gelöschte Elemente wiederherstellen oder sie endgültig aus dem System entfernen. Diese Liste ist ungefiltert, im Gegensatz zu ähnlichen Aktivitätslisten im System, bei denen Berechtigungsfilter angewendet werden.', + 'recycle_bin_deleted_item' => 'Gelöschter Eintrag', 'recycle_bin_deleted_parent' => 'Übergeordnet', 'recycle_bin_deleted_by' => 'Gelöscht von', 'recycle_bin_deleted_at' => 'Löschzeitpunkt', - 'recycle_bin_permanently_delete' => 'Dauerhaft löschen', + 'recycle_bin_permanently_delete' => 'Endgültig löschen', 'recycle_bin_restore' => 'Wiederherstellen', 'recycle_bin_contents_empty' => 'Der Papierkorb ist derzeit leer', 'recycle_bin_empty' => 'Papierkorb leeren', - 'recycle_bin_empty_confirm' => 'Dies wird alle Gegenstände im Papierkorb dauerhaft entfernen, einschließlich der Inhalte, die darin enthalten sind. Sind Sie sicher, dass Sie den Papierkorb leeren möchten?', - 'recycle_bin_destroy_confirm' => 'Diese Aktion löscht dieses Element dauerhaft aus dem System, zusammen mit allen unten aufgeführten untergeordneten Elementen, und es ist nicht möglich, diesen Inhalt wiederherzustellen. Sind Sie sicher, dass Sie dieses Element dauerhaft löschen möchten?', + 'recycle_bin_empty_confirm' => 'Dadurch werden alle Elemente im Papierkorb sowie deren Inhalte endgültig gelöscht. Möchten Sie den Papierkorb wirklich leeren?', + 'recycle_bin_destroy_confirm' => 'Durch diesen Vorgang wird dieses Element zusammen mit allen unten aufgeführten untergeordneten Elementen endgültig aus dem System gelöscht, und Sie können diesen Inhalt nicht wiederherstellen. Möchten Sie dieses Element wirklich endgültig löschen?', 'recycle_bin_destroy_list' => 'Zu löschende Elemente', 'recycle_bin_restore_list' => 'Zu wiederherzustellende Elemente', - 'recycle_bin_restore_confirm' => 'Mit dieser Aktion wird das gelöschte Element einschließlich aller untergeordneten Elemente an seinen ursprünglichen Ort wiederherstellen. Wenn der ursprüngliche Ort gelöscht wurde und sich nun im Papierkorb befindet, muss auch das übergeordnete Element wiederhergestellt werden.', - 'recycle_bin_restore_deleted_parent' => 'Das übergeordnete Elements wurde ebenfalls gelöscht. Dieses Element wird weiterhin als gelöscht zählen, bis auch das übergeordnete Element wiederhergestellt wurde.', + 'recycle_bin_restore_confirm' => 'Durch diese Aktion wird das gelöschte Element einschließlich aller untergeordneten Elemente an seinem ursprünglichen Speicherort wiederhergestellt. Sollte der ursprüngliche Speicherort inzwischen gelöscht worden sein und sich nun im Papierkorb befinden, muss auch das übergeordnete Element wiederhergestellt werden.', + 'recycle_bin_restore_deleted_parent' => 'Das übergeordnete Element dieses Eintrags wurde ebenfalls gelöscht. Diese Einträge bleiben gelöscht, bis auch das übergeordnete Element wiederhergestellt wird.', 'recycle_bin_restore_parent' => 'Übergeordneter Eintrag wiederherstellen', - 'recycle_bin_destroy_notification' => ':count Elemente wurden aus dem Papierkorb gelöscht.', - 'recycle_bin_restore_notification' => ':count Elemente wurden aus dem Papierkorb wiederhergestellt.', + 'recycle_bin_destroy_notification' => 'Löscht :count Elemente aus dem Papierkorb.', + 'recycle_bin_restore_notification' => 'Es wurden :count der Elemente aus dem Papierkorb wiederhergestellt.', // Audit Log - 'audit' => 'Audit-Protokoll', - 'audit_desc' => 'Dieses Audit-Protokoll zeigt eine Liste der Aktivitäten an, welche vom System protokolliert werden. Im Gegensatz zu den anderen Aktivitätslisten im System, bei denen Berechtigungen angewendet werden, ist diese Liste ungefiltert.', + 'audit' => 'Prüfprotokoll', + 'audit_desc' => 'Dieses Protokoll zeigt eine Liste der im System erfassten Aktivitäten an. Im Gegensatz zu ähnlichen Aktivitätslisten im System, bei denen Berechtigungsfilter angewendet werden, ist diese Liste ungefiltert.', 'audit_event_filter' => 'Ereignisfilter', 'audit_event_filter_no_filter' => 'Kein Filter', 'audit_deleted_item' => 'Gelöschtes Objekt', @@ -169,133 +168,133 @@ Hinweis: Benutzer können ihre E-Mail-Adresse nach erfolgreicher Registrierung // Role Settings 'roles' => 'Rollen', 'role_user_roles' => 'Benutzer-Rollen', - 'roles_index_desc' => 'Rollen werden verwendet, um Benutzer zu gruppieren System-Berechtigung für ihre Mitglieder zuzuweisen. Wenn ein Benutzer Mitglied mehrerer Rollen ist, stapeln die gewährten Berechtigungen und der Benutzer wird alle Fähigkeiten erben.', + 'roles_index_desc' => 'Rollen dienen dazu, Benutzer zu gruppieren und ihren Mitgliedern Systemberechtigungen zu erteilen. Wenn ein Benutzer Mitglied mehrerer Rollen ist, addieren sich die gewährten Berechtigungen, und der Benutzer erhält alle entsprechenden Befugnisse.', 'roles_x_users_assigned' => ':count Benutzer zugewiesen|:count Benutzer zugewiesen', 'roles_x_permissions_provided' => ':count Berechtigung|:count Berechtigungen', 'roles_assigned_users' => 'Zugewiesene Benutzer', - 'roles_permissions_provided' => 'Genutzte Berechtigungen', - 'role_create' => 'Neue Rolle anlegen', + 'roles_permissions_provided' => 'Verfügbare Berechtigungen', + 'role_create' => 'Neue Rolle erstellen', 'role_delete' => 'Rolle löschen', - 'role_delete_confirm' => 'Sie möchten die Rolle ":roleName" löschen.', - 'role_delete_users_assigned' => 'Diese Rolle ist :userCount Benutzern zugeordnet. Sie können unten eine neue Rolle auswählen, die Sie diesen Benutzern zuordnen möchten.', + 'role_delete_confirm' => 'Dadurch wird die Rolle mit dem Namen „:roleName“ gelöscht.', + 'role_delete_users_assigned' => 'Dieser Rolle sind :userCount Benutzer zugewiesen. Wenn Sie die Benutzer aus dieser Rolle migrieren möchten, wählen Sie unten eine neue Rolle aus.', 'role_delete_no_migration' => "Den Benutzern keine andere Rolle zuordnen", - 'role_delete_sure' => 'Sind Sie sicher, dass Sie diese Rolle löschen möchten?', + 'role_delete_sure' => 'Möchten Sie diese Rolle wirklich löschen?', 'role_edit' => 'Rolle bearbeiten', 'role_details' => 'Rollendetails', 'role_name' => 'Rollenname', 'role_desc' => 'Kurzbeschreibung der Rolle', - 'role_mfa_enforced' => 'Benötigt Mehrfach-Faktor-Authentifizierung', + 'role_mfa_enforced' => 'Erfordert eine Multi-Faktor-Authentifizierung', 'role_external_auth_id' => 'Externe Authentifizierungs-IDs', 'role_system' => 'System-Berechtigungen', 'role_manage_users' => 'Benutzer verwalten', - 'role_manage_roles' => 'Rollen und Rollen-Berechtigungen verwalten', - 'role_manage_entity_permissions' => 'Alle Buch-, Kapitel- und Seiten-Berechtigungen verwalten', - 'role_manage_own_entity_permissions' => 'Nur Berechtigungen eigener Bücher, Kapitel und Seiten verwalten', + 'role_manage_roles' => 'Rollen und Rollenberechtigungen verwalten', + 'role_manage_entity_permissions' => 'Alle Berechtigungen für Bücher, Kapitel und Seiten verwalten', + 'role_manage_own_entity_permissions' => 'Berechtigungen für das eigene Buch, Kapitel und Seiten verwalten', 'role_manage_page_templates' => 'Seitenvorlagen verwalten', 'role_access_api' => 'Systemzugriffs-API', 'role_manage_settings' => 'Globaleinstellungen verwalten', 'role_export_content' => 'Inhalt exportieren', 'role_import_content' => 'Inhalt importieren', 'role_editor_change' => 'Seiten-Editor ändern', - 'role_notifications' => 'Empfangen und Verwalten von Benachrichtigungen', - 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', + 'role_notifications' => 'Benachrichtigungen empfangen und verwalten', + 'role_permission_note_users_and_roles' => 'Diese Berechtigungen ermöglichen technisch gesehen auch die Anzeige und Suche nach Benutzern und Rollen im System.', 'role_asset' => 'Berechtigungen', - 'roles_system_warning' => 'Beachten Sie, dass der Zugriff auf eine der oben genannten drei Berechtigungen einem Benutzer erlauben kann, seine eigenen Berechtigungen oder die Rechte anderer im System zu ändern. Weisen Sie nur Rollen, mit diesen Berechtigungen, vertrauenswürdigen Benutzern zu.', - 'role_asset_desc' => 'Diese Berechtigungen gelten für den Standard-Zugriff innerhalb des Systems. Berechtigungen für Bücher, Kapitel und Seiten überschreiben diese Berechtigungenen.', - 'role_asset_admins' => 'Administratoren erhalten automatisch Zugriff auf alle Inhalte, aber diese Optionen können Oberflächenoptionen ein- oder ausblenden.', - 'role_asset_image_view_note' => 'Das bezieht sich auf die Sichtbarkeit innerhalb des Bildmanagers. Der tatsächliche Zugriff auf hochgeladene Bilddateien hängt von der Speicheroption des Systems für Bilder ab.', - 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', + 'roles_system_warning' => 'Beachten Sie, dass der Zugriff auf eine der drei oben genannten Berechtigungen es einem Benutzer ermöglichen kann, seine eigenen Berechtigungen oder die Berechtigungen anderer Benutzer im System zu ändern. Weisen Sie Rollen mit diesen Berechtigungen nur vertrauenswürdigen Benutzern zu.', + 'role_asset_desc' => 'Diese Berechtigungen regeln den Standard mäßigen Zugriff auf die Assets im System. Berechtigungen für Bücher, Kapitel und Seiten haben Vorrang vor diesen Berechtigungen.', + 'role_asset_admins' => 'Administratoren erhalten automatisch Zugriff auf alle Inhalte, doch mit diesen Optionen können Elemente der Benutzeroberfläche ein- oder ausgeblendet werden.', + 'role_asset_image_view_note' => 'Dies betrifft die Sichtbarkeit innerhalb des Bildmanagers. Der tatsächliche Zugriff auf hochgeladene Bilddateien hängt von der gewählten Speicheroption des Systems ab.', + 'role_asset_users_note' => 'Diese Berechtigungen ermöglichen technisch gesehen auch die Anzeige und Suche nach Benutzern im System.', 'role_all' => 'Alle', 'role_own' => 'Eigene', - 'role_controlled_by_asset' => 'Berechtigungen werden vom Uploadziel bestimmt', + 'role_controlled_by_asset' => 'Abhängig von dem Asset, in das sie hochgeladen werden', 'role_save' => 'Rolle speichern', 'role_users' => 'Dieser Rolle zugeordnete Benutzer', - 'role_users_none' => 'Bisher sind dieser Rolle keine Benutzer zugeordnet', + 'role_users_none' => 'Derzeit sind diesem Rollentyp keine Benutzer zugewiesen', // Users 'users' => 'Benutzer', - 'users_index_desc' => 'Erstellen und Verwalten Sie individuelle Benutzerkonten innerhalb des Systems. Benutzerkonten werden zur Anmeldung und Besitz von Inhalten und Aktivitäten verwendet. Zugriffsberechtigungen sind in erster Linie rollenbasiert, aber Besitz von Benutzerinhalten kann unter anderem auch Berechtigungen beeinflussen.', + 'users_index_desc' => 'Erstellen und verwalten Sie individuelle Benutzerkonten innerhalb des Systems. Benutzerkonten dienen der Anmeldung sowie der Zuordnung von Inhalten und Aktivitäten. Zugriffsberechtigungen sind in erster Linie rollen basiert, doch unter anderem kann auch die Eigentümerschaft an Inhalten durch den Benutzer die Berechtigungen und den Zugriff beeinflussen.', 'user_profile' => 'Benutzerprofil', 'users_add_new' => 'Benutzer hinzufügen', 'users_search' => 'Benutzer suchen', 'users_latest_activity' => 'Neueste Aktivitäten', 'users_details' => 'Benutzerdetails', 'users_details_desc' => 'Legen Sie für diesen Benutzer einen Anzeigenamen und eine E-Mail-Adresse fest. Die E-Mail-Adresse wird bei der Anmeldung verwendet.', - 'users_details_desc_no_email' => 'Legen Sie für diesen Benutzer einen Anzeigenamen fest, damit andere ihn erkennen können.', + 'users_details_desc_no_email' => 'Legen Sie einen Anzeigenamen für diesen Benutzer fest, damit andere ihn erkennen können.', 'users_role' => 'Benutzerrollen', - 'users_role_desc' => 'Wählen Sie aus, welchen Rollen dieser Benutzer zugeordnet werden soll. Wenn ein Benutzer mehreren Rollen zugeordnet ist, werden die Berechtigungen dieser Rollen gestapelt und er erhält alle Fähigkeiten der zugewiesenen Rollen.', + 'users_role_desc' => 'Wählen Sie aus, welchen Rollen dieser Benutzer zugewiesen werden soll. Wenn einem Benutzer mehrere Rollen zugewiesen sind, stapeln sich die Berechtigungen dieser Rollen, und er erhält alle Funktionen der zugewiesenen Rollen.', 'users_password' => 'Benutzerpasswort', - 'users_password_desc' => 'Legen Sie ein Passwort fest, mit dem Sie sich anmelden möchten. Diese muss mindestens 8 Zeichen lang sein.', - 'users_send_invite_text' => 'Sie können diesem Benutzer eine Einladungs-E-Mail senden, die es ihm erlaubt, sein eigenes Passwort zu setzen, andernfalls können Sie sein Passwort selbst setzen.', - 'users_send_invite_option' => 'Benutzer-Einladungs-E-Mail senden', + 'users_password_desc' => 'Legen Sie ein Passwort fest, mit dem Sie sich bei der Anwendung anmelden. Es muss mindestens 8 Zeichen lang sein.', + 'users_send_invite_text' => 'Sie können diesem Benutzer eine Einladungs-E-Mail senden, mit der er sein eigenes Passwort festlegen kann, oder Sie können das Passwort selbst festlegen.', + 'users_send_invite_option' => 'Es erfolgt die Zusendung einer E-Mail mit einer Einladung an den Nutzer', 'users_external_auth_id' => 'Externe Authentifizierungs-ID', - 'users_external_auth_id_desc' => 'Wenn ein externes Authentifizierungssystem verwendet wird (z. B. SAML2, OIDC oder LDAP) ist dies die ID, die diesen BookStack-Benutzer mit dem Authentifizierungs-Systemkonto verknüpft. Sie können dieses Feld ignorieren, wenn Sie die Standard-E-Mail-basierte Authentifizierung verwenden.', + 'users_external_auth_id_desc' => 'Wenn ein externes Authentifizierungssystem verwendet wird (z. B. SAML2, OIDC oder LDAP), ist dies die ID, die diesen BookStack Benutzer mit dem Konto im Authentifizierungssystem verknüpft. Bei Verwendung der Standard mäßigen E-Mail-basierten Authentifizierung können Sie dieses Feld ignorieren.', 'users_password_warning' => 'Füllen Sie die untenstehenden Felder nur aus, wenn Sie das Passwort für diesen Benutzer ändern möchten.', - 'users_system_public' => 'Dieser Benutzer repräsentiert alle unangemeldeten Benutzer, die diese Seite betrachten. Er kann nicht zum Anmelden benutzt werden, sondern wird automatisch zugeordnet.', + 'users_system_public' => 'Dieser Benutzer steht für alle Gastbenutzer, die Ihre Instanz besuchen. Er kann nicht zum Einloggen verwendet werden, wird jedoch automatisch zugewiesen.', 'users_delete' => 'Benutzer löschen', 'users_delete_named' => 'Benutzer ":userName" löschen', - 'users_delete_warning' => 'Der Benutzer ":userName" wird aus dem System gelöscht.', - 'users_delete_confirm' => 'Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?', - 'users_migrate_ownership' => 'Besitz migrieren', - 'users_migrate_ownership_desc' => 'Wählen Sie hier einen Benutzer, wenn Sie möchten, dass ein anderer Benutzer der Besitzer aller Einträge wird, die diesem Benutzer derzeit gehören.', + 'users_delete_warning' => 'Dadurch wird der Benutzer mit dem Namen „:userName“ vollständig aus dem System gelöscht.', + 'users_delete_confirm' => 'Möchten Sie diesen Benutzer wirklich löschen?', + 'users_migrate_ownership' => 'Eigentumsverhältnisse übertragen', + 'users_migrate_ownership_desc' => 'Wählen Sie hier einen Benutzer aus, wenn Sie möchten, dass ein anderer Benutzer Eigentümer aller Elemente wird, die derzeit diesem Benutzer gehören.', 'users_none_selected' => 'Kein Benutzer ausgewählt', 'users_edit' => 'Benutzer bearbeiten', 'users_edit_profile' => 'Profil bearbeiten', - 'users_avatar' => 'Benutzer-Bild', - 'users_avatar_desc' => 'Das Bild sollte eine Auflösung von 256x256px haben.', + 'users_avatar' => 'Benutzer-Avatar', + 'users_avatar_desc' => 'Wähle ein Bild aus, das diesen Benutzer repräsentieren soll. Es sollte etwa 256px im Quadrat groß sein.', 'users_preferred_language' => 'Bevorzugte Sprache', - 'users_preferred_language_desc' => 'Diese Option ändert die Sprache, die für die Benutzeroberfläche der Anwendung verwendet wird. Dies hat keinen Einfluss auf von Benutzern erstellte Inhalte.', - 'users_social_accounts' => 'Social-Media Konten', - 'users_social_accounts_desc' => 'Zeigt den Status der verbundenen sozialen Konten für diesen Benutzer an. Social Accounts können zusätzlich zum primären Authentifizierungssystem für den Systemzugriff verwendet werden.', - 'users_social_accounts_info' => 'Hier können Sie andere Social-Media-Konten für eine schnellere und einfachere Anmeldung verknüpfen. Wenn Sie ein Social-Media Konto lösen, bleibt der Zugriff erhalten. Entfernen Sie in diesem Falle die Berechtigung in Ihren Profil-Einstellungen des verknüpften Social-Media-Kontos.', - 'users_social_connect' => 'Social-Media-Konto verknüpfen', - 'users_social_disconnect' => 'Social-Media-Konto löschen', + 'users_preferred_language_desc' => 'Mit dieser Option können Sie die Sprache der Benutzeroberfläche der Anwendung ändern. Dies hat keine Auswirkungen auf von Benutzern erstellte Inhalte.', + 'users_social_accounts' => 'Social-Media-Konten', + 'users_social_accounts_desc' => 'Den Status der mit diesem Benutzer verknüpften Social-Media-Konten anzeigen. Social-Media-Konten können zusätzlich zum primären Authentifizierungssystem für den Systemzugang verwendet werden.', + 'users_social_accounts_info' => 'Hier kannst du deine anderen Konten verknüpfen, um dich schneller und einfacher anzumelden. Wenn du ein Konto hier entkoppelst, wird der zuvor erteilte Zugriff dadurch nicht widerrufen. Den Zugriff kannst du in den Profileinstellungen des verknüpften sozialen Kontos widerrufen.', + 'users_social_connect' => 'Konto verbinden', + 'users_social_disconnect' => 'Konto trennen', 'users_social_status_connected' => 'Verbunden', - 'users_social_status_disconnected' => 'Getrennt', - 'users_social_connected' => ':socialAccount-Konto wurde erfolgreich mit dem Profil verknüpft.', - 'users_social_disconnected' => ':socialAccount-Konto wurde erfolgreich vom Profil gelöst.', + 'users_social_status_disconnected' => 'Nicht verbunden', + 'users_social_connected' => ':socialAccount wurde erfolgreich mit Ihrem Profil verknüpft.', + 'users_social_disconnected' => ':socialAccount wurde erfolgreich von Ihrem Profil getrennt.', 'users_api_tokens' => 'API-Token', - 'users_api_tokens_desc' => 'Erstellen und verwalten Sie die Zugangs-Tokens zur Authentifizierung mit der BookStack REST API. Berechtigungen für die API werden über den Benutzer verwaltet, dem das Token gehört.', + 'users_api_tokens_desc' => 'Erstellen und verwalten Sie die Zugriffstoken, die zur Authentifizierung bei der BookStack REST API verwendet werden. Die Berechtigungen für die API werden über den Benutzer verwaltet, dem das Token zugeordnet ist.', 'users_api_tokens_none' => 'Für diesen Benutzer wurden keine API-Token erstellt', 'users_api_tokens_create' => 'Token erstellen', - 'users_api_tokens_expires' => 'Endet', + 'users_api_tokens_expires' => 'Gültig bis', 'users_api_tokens_docs' => 'API Dokumentation', 'users_mfa' => 'Multi-Faktor-Authentifizierung', - 'users_mfa_desc' => 'Richten Sie Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für Ihr Benutzerkonto ein.', + 'users_mfa_desc' => 'Richten Sie die Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für Ihr Benutzerkonto ein.', 'users_mfa_x_methods' => ':count Methode konfiguriert|:count Methoden konfiguriert', 'users_mfa_configure' => 'Methoden konfigurieren', // API Tokens 'user_api_token_create' => 'Neuen API-Token erstellen', 'user_api_token_name' => 'Name', - 'user_api_token_name_desc' => 'Geben Sie Ihrem Token einen aussagekräftigen Namen als spätere Erinnerung an seinen Verwendungszweck.', + 'user_api_token_name_desc' => 'Gib deinem Token einen aussagekräftigen Namen, damit du später noch weißt, wofür er gedacht ist.', 'user_api_token_expiry' => 'Ablaufdatum', - 'user_api_token_expiry_desc' => 'Legen Sie ein Datum fest, an dem dieser Token abläuft. Nach diesem Datum funktionieren Anfragen, die mit diesem Token gestellt werden, nicht mehr. Wenn Sie dieses Feld leer lassen, wird ein Ablaufdatum von 100 Jahren in der Zukunft festgelegt.', - 'user_api_token_create_secret_message' => 'Unmittelbar nach der Erstellung dieses Tokens wird eine "Token ID" & ein "Token Kennwort" generiert und angezeigt. Das Kennwort wird nur ein einziges Mal angezeigt. Stellen Sie also sicher, dass Sie den Inhalt an einen sicheren Ort kopieren, bevor Sie fortfahren.', + 'user_api_token_expiry_desc' => 'Legen Sie ein Ablaufdatum für dieses Token fest. Nach diesem Datum funktionieren Anfragen, die mit diesem Token gestellt werden, nicht mehr. Wenn Sie dieses Feld leer lassen, wird das Ablaufdatum auf 100 Jahre in der Zukunft gesetzt.', + 'user_api_token_create_secret_message' => 'Unmittelbar nach der Erstellung dieses Tokens werden eine „Token-ID“ und ein „Token-Geheimnis“ generiert und angezeigt. Das Geheimnis wird nur einmal angezeigt. Kopieren Sie den Wert daher unbedingt an einen sicheren Ort, bevor Sie fortfahren.', 'user_api_token' => 'API-Token', 'user_api_token_id' => 'Token ID', - 'user_api_token_id_desc' => 'Dies ist ein nicht editierbarer, vom System generierter Identifikator für diesen Token, welcher bei API-Anfragen angegeben werden muss.', - 'user_api_token_secret' => 'Token Kennwort', - 'user_api_token_secret_desc' => 'Dies ist ein systemgeneriertes Kennwort für diesen Token, das bei API-Anfragen zur Verfügung gestellt werden muss. Es wird nur dieses eine Mal angezeigt, deshalb kopieren Sie diesen Wert an einen sicheren und geschützten Ort.', - 'user_api_token_created' => 'Token erstellt :timeAgo', + 'user_api_token_id_desc' => 'Dies ist eine nicht bearbeitbare, vom System generierte Kennung für dieses Token, die in API-Anfragen angegeben werden muss.', + 'user_api_token_secret' => 'Token Geheimnis', + 'user_api_token_secret_desc' => 'Dies ist ein vom System generiertes Geheimnis für dieses Token, das in API-Anfragen angegeben werden muss. Es wird nur dieses eine Mal angezeigt; speichern Sie diesen Wert daher an einem sicheren Ort.', + 'user_api_token_created' => 'Token erstellt vor :timeAgo', 'user_api_token_updated' => 'Token aktualisiert :timeAgo', 'user_api_token_delete' => 'Lösche Token', - 'user_api_token_delete_warning' => 'Dies löscht den API-Token mit dem Namen \':tokenName\' vollständig aus dem System.', - 'user_api_token_delete_confirm' => 'Sind Sie sicher, dass Sie diesen API-Token löschen möchten?', + 'user_api_token_delete_warning' => 'Dadurch wird dieser API-Token mit dem Namen „:tokenName“ vollständig aus dem System gelöscht.', + 'user_api_token_delete_confirm' => 'Möchten Sie diesen API-Token wirklich löschen?', // Webhooks 'webhooks' => 'Webhooks', - 'webhooks_index_desc' => 'Webhooks sind eine Möglichkeit, Daten an externe URLs zu senden, wenn bestimmte Aktionen und Ereignisse im System auftreten, was eine ereignisbasierte Integration mit externen Plattformen wie Messaging- oder Benachrichtigungssystemen ermöglicht.', - 'webhooks_x_trigger_events' => ':count Auslöserereignis|:count Auslöserereignisse', + 'webhooks_index_desc' => 'Webhooks sind eine Möglichkeit, Daten an externe URLs zu senden, wenn bestimmte Aktionen und Ereignisse innerhalb des Systems auftreten. Dies ermöglicht eine Ereignis-basierte Integration mit externen Plattformen wie Messaging oder Benachrichtigungssystemen.', + 'webhooks_x_trigger_events' => ':count ausgelöstes Ereignis|:count ausgelöste Ereignisse', 'webhooks_create' => 'Neuen Webhook erstellen', 'webhooks_none_created' => 'Es wurden noch keine Webhooks erstellt.', 'webhooks_edit' => 'Webhook bearbeiten', 'webhooks_save' => 'Webhook speichern', 'webhooks_details' => 'Webhook-Details', - 'webhooks_details_desc' => 'Geben Sie einen benutzerfreundlichen Namen und einen POST-Endpunkt als Ort an, an den die Webhook-Daten gesendet werden sollen.', + 'webhooks_details_desc' => 'Geben Sie einen benutzerfreundlichen Namen und einen POST-Endpunkt als Ziel für die zu sendenden Webhook-Daten an.', 'webhooks_events' => 'Webhook Ereignisse', - 'webhooks_events_desc' => 'Wählen Sie alle Ereignisse, die diesen Webhook auslösen sollen.', - 'webhooks_events_warning' => 'Beachten Sie, dass diese Ereignisse für alle ausgewählten Ereignisse ausgelöst werden, auch wenn benutzerdefinierte Berechtigungen angewendet werden. Stellen Sie sicher, dass die Verwendung dieses Webhook keine vertraulichen Inhalte enthüllt.', + 'webhooks_events_desc' => 'Wählen Sie alle Ereignisse aus, die den Aufruf dieses Webhooks auslösen sollen.', + 'webhooks_events_warning' => 'Beachten Sie, dass diese Ereignisse für alle ausgewählten Ereignisse ausgelöst werden, auch wenn benutzerdefinierte Berechtigungen gelten. Stellen Sie sicher, dass durch die Verwendung dieses Webhooks keine vertraulichen Inhalte offengelegt werden.', 'webhooks_events_all' => 'Alle System-Ereignisse', 'webhooks_name' => 'Webhook-Name', 'webhooks_timeout' => 'Webhook Request Timeout (Sekunden)', @@ -303,10 +302,10 @@ Hinweis: Benutzer können ihre E-Mail-Adresse nach erfolgreicher Registrierung 'webhooks_active' => 'Webhook aktiv', 'webhook_events_table_header' => 'Ereignisse', 'webhooks_delete' => 'Webhook löschen', - 'webhooks_delete_warning' => 'Dies wird diesen Webhook mit dem Namen \':webhookName\' vollständig aus dem System löschen.', - 'webhooks_delete_confirm' => 'Sind Sie sicher, dass Sie diesen Webhook löschen möchten?', - 'webhooks_format_example' => 'Webhook Format Beispiel', - 'webhooks_format_example_desc' => 'Webhook Daten werden als POST-Anfrage an den konfigurierten Endpunkt als JSON im folgenden Format gesendet. Die Eigenschaften "related_item" und "url" sind optional und hängen vom Typ des ausgelösten Ereignisses ab.', + 'webhooks_delete_warning' => 'Dadurch wird dieser Webhook mit dem Namen „:webhookName“ vollständig aus dem System gelöscht.', + 'webhooks_delete_confirm' => 'Möchtest du diesen Webhook wirklich löschen?', + 'webhooks_format_example' => 'Beispiel für ein Webhook-Format', + 'webhooks_format_example_desc' => 'Webhook-Daten werden als POST-Anfrage im JSON-Format an den konfigurierten Endpunkt gesendet und entsprechen dabei dem unten angegebenen Format. Die Eigenschaften „related_item“ und „url“ sind optional und hängen von der Art des ausgelösten Ereignisses ab.', 'webhooks_status' => 'Webhook-Status', 'webhooks_last_called' => 'Zuletzt aufgerufen:', 'webhooks_last_errored' => 'Letzter Fehler:', @@ -314,11 +313,11 @@ Hinweis: Benutzer können ihre E-Mail-Adresse nach erfolgreicher Registrierung // Licensing 'licenses' => 'Lizenzen', - 'licenses_desc' => 'Diese Seite beschreibt Lizenzinformationen für BookStack zusätzlich zu den Projekten und Bibliotheken, die in BookStack verwendet werden. Viele aufgelistete Projekte dürfen nur in einem Entwicklungskontext verwendet werden.', - 'licenses_bookstack' => 'BookStack-Lizenz', - 'licenses_php' => 'PHP-Bibliothekslizenzen', - 'licenses_js' => 'JavaScript-Bibliothekslizenzen', - 'licenses_other' => 'Andere Lizenzen', + 'licenses_desc' => 'Auf dieser Seite finden Sie Lizenzinformationen zu BookStack sowie zu den Projekten und Bibliotheken, die in BookStack verwendet werden. Viele der aufgeführten Projekte dürfen möglicherweise nur im Entwicklungskontext genutzt werden.', + 'licenses_bookstack' => 'BookStack Lizenz', + 'licenses_php' => 'Lizenzen für PHP-Bibliotheken', + 'licenses_js' => 'Lizenzen für JavaScript-Bibliotheken', + 'licenses_other' => 'Sonstige Lizenzen', 'license_details' => 'Lizenzdetails', //! If editing translations files directly please ignore this in all diff --git a/lang/de_informal/entities.php b/lang/de_informal/entities.php index a3e6e5f36..16502051c 100644 --- a/lang/de_informal/entities.php +++ b/lang/de_informal/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'Dies löscht die hochgeladene ZIP-Datei und kann nicht rückgängig gemacht werden.', 'import_errors' => 'Importfehler', 'import_errors_desc' => 'Die folgenden Fehler sind während des Importversuchs aufgetreten:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigiere in Büchern', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Durch die untergeordneten Elemente der Seite navigieren', + 'breadcrumb_siblings_for_chapter' => 'Durch die Unterelemente des Kapitels navigieren', + 'breadcrumb_siblings_for_book' => 'Durch die Unterordner des Buches navigieren', + 'breadcrumb_siblings_for_bookshelf' => 'Durch die untergeordneten Elemente des Regals navigieren', // Permissions and restrictions 'permissions' => 'Berechtigungen', diff --git a/lang/de_informal/settings.php b/lang/de_informal/settings.php index fa175187b..97ad607d6 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -76,8 +76,8 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'reg_confirm_restrict_domain_placeholder' => 'Keine Einschränkung gesetzt', // Sorting Settings - 'sorting' => 'Lists & Sorting', - 'sorting_book_default' => 'Default Book Sort Rule', + 'sorting' => 'Listen & Sortieren', + 'sorting_book_default' => 'Standardregel für die Sortierung von Büchern', 'sorting_book_default_desc' => 'Wähle die Standard-Sortierregel aus, die auf neue Bücher angewendet werden soll. Dies wirkt sich nicht auf bestehende Bücher aus und kann pro Buch überschrieben werden.', 'sorting_rules' => 'Sortierregeln', 'sorting_rules_desc' => 'Dies sind vordefinierte Sortieraktionen, die auf Inhalte im System angewendet werden können.', @@ -104,8 +104,8 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'sort_rule_op_updated_date' => 'Aktualisierungsdatum', 'sort_rule_op_chapters_first' => 'Kapitel zuerst', 'sort_rule_op_chapters_last' => 'Kapitel zuletzt', - 'sorting_page_limits' => 'Per-Page Display Limits', - 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using a multiple of 6 is recommended.', + 'sorting_page_limits' => 'Anzeigebegrenzungen pro Seite', + 'sorting_page_limits_desc' => 'Legen Sie fest, wie viele Elemente pro Seite in den verschiedenen Listen des Systems angezeigt werden sollen. In der Regel ist eine geringere Anzahl leistungsfähiger, während eine höhere Anzahl das Blättern durch mehrere Seiten überflüssig macht. Es wird empfohlen, ein Vielfaches von 6 zu verwenden.', // Maintenance settings 'maint' => 'Wartung', @@ -198,13 +198,13 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'role_import_content' => 'Inhalt importieren', 'role_editor_change' => 'Seiteneditor ändern', 'role_notifications' => 'Empfangen und Verwalten von Benachrichtigungen', - 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', + 'role_permission_note_users_and_roles' => 'Diese Berechtigungen ermöglichen technisch gesehen auch die Anzeige und Suche nach Benutzern und Rollen im System.', 'role_asset' => 'Berechtigungen', 'roles_system_warning' => 'Beachte, dass der Zugriff auf eine der oben genannten drei Berechtigungen einem Benutzer erlauben kann, seine eigenen Berechtigungen oder die Rechte anderer im System zu ändern. Weise nur Rollen mit diesen Berechtigungen vertrauenswürdigen Benutzern zu.', 'role_asset_desc' => 'Diese Berechtigungen gelten für den Standard-Zugriff innerhalb des Systems. Berechtigungen für Bücher, Kapitel und Seiten überschreiben diese Berechtigungen.', 'role_asset_admins' => 'Administratoren erhalten automatisch Zugriff auf alle Inhalte, aber diese Optionen können Oberflächenoptionen ein- oder ausblenden.', 'role_asset_image_view_note' => 'Das bezieht sich auf die Sichtbarkeit innerhalb des Bildmanagers. Der tatsächliche Zugriff auf hochgeladene Bilddateien hängt von der Speicheroption des Systems für Bilder ab.', - 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', + 'role_asset_users_note' => 'Diese Berechtigungen ermöglichen technisch gesehen auch die Anzeige und Suche nach Benutzern im System.', 'role_all' => 'Alle', 'role_own' => 'Eigene', 'role_controlled_by_asset' => 'Berechtigungen werden vom Uploadziel bestimmt', diff --git a/lang/ko/editor.php b/lang/ko/editor.php index b5c4ae807..a274586c1 100644 --- a/lang/ko/editor.php +++ b/lang/ko/editor.php @@ -178,5 +178,5 @@ return [ 'shortcuts_intro' => '편집기에서 사용할 수 있는 바로 가기는 다음과 같습니다:', 'windows_linux' => '(윈도우/리눅스)', 'mac' => '(맥)', - 'description' => '상세정보', + 'description' => '설명', ]; diff --git a/lang/ko/errors.php b/lang/ko/errors.php index 9fa56ffcd..ca7c54573 100644 --- a/lang/ko/errors.php +++ b/lang/ko/errors.php @@ -71,8 +71,8 @@ return [ 'book_not_found' => '책이 없습니다.', 'page_not_found' => '문서가 없습니다.', 'chapter_not_found' => '챕터가 없습니다.', - 'selected_book_not_found' => '고른 책이 없습니다.', - 'selected_book_chapter_not_found' => '고른 책이나 챕터가 없습니다.', + 'selected_book_not_found' => '선택된 책이 없습니다.', + 'selected_book_chapter_not_found' => '선택된 책이나 챕터가 없습니다', 'guests_cannot_save_drafts' => 'Guest는 초안 문서를 보관할 수 없습니다.', // Users diff --git a/lang/pt/common.php b/lang/pt/common.php index a0d83ac1e..3ffc6680b 100644 --- a/lang/pt/common.php +++ b/lang/pt/common.php @@ -30,8 +30,8 @@ return [ 'create' => 'Criar', 'update' => 'Atualizar', 'edit' => 'Editar', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => 'Arquivar', + 'unarchive' => 'Desarquivar', 'sort' => 'Ordenar', 'move' => 'Mover', 'copy' => 'Copiar', diff --git a/lang/pt/editor.php b/lang/pt/editor.php index b60f813b5..e3069909c 100644 --- a/lang/pt/editor.php +++ b/lang/pt/editor.php @@ -13,7 +13,7 @@ return [ 'cancel' => 'Cancelar', 'save' => 'Guardar', 'close' => 'Fechar', - 'apply' => 'Apply', + 'apply' => 'Aplicar', 'undo' => 'Anular', 'redo' => 'Refazer', 'left' => 'Esquerda', @@ -166,7 +166,7 @@ return [ 'about' => 'Sobre o editor', 'about_title' => 'Sobre o Editor WYSIWYG', 'editor_license' => 'Editor da licença de direitos autorais', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license' => 'Este editor é criado como um fork do :lexicaLink que é distribuído sob a licença MIT.', 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Este editor foi criado com :tinyLink que é fornecido sob a licença MIT.', 'editor_tiny_license_link' => 'Os dados relativos aos direitos de autor e à licença do TinyMCE podem ser encontrados aqui.', diff --git a/lang/pt/entities.php b/lang/pt/entities.php index 91fe6d90c..e882c79d0 100644 --- a/lang/pt/entities.php +++ b/lang/pt/entities.php @@ -23,7 +23,7 @@ return [ 'meta_updated' => 'Atualizado :timeLength', 'meta_updated_name' => 'Atualizado :timeLength por :user', 'meta_owned_name' => 'Propriedade de :user', - 'meta_reference_count' => 'Referenced by :count item|Referenced by :count items', + 'meta_reference_count' => 'Referenciado por :count item├Referenciado por :count itens', 'entity_select' => 'Seleção de Entidade', 'entity_select_lack_permission' => 'Não tem as permissões necessárias para selecionar este item', 'images' => 'Imagens', @@ -39,13 +39,13 @@ return [ 'export_pdf' => 'Arquivo PDF', 'export_text' => 'Arquivo Texto', 'export_md' => 'Ficheiro Markdown', - 'export_zip' => 'Portable ZIP', - 'default_template' => 'Default Page Template', - 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', - 'default_template_select' => 'Select a template 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.', + 'export_zip' => 'ZIP Portátil', + 'default_template' => 'Modelo de página padrão', + 'default_template_explain' => 'Atribuir um modelo de página que será usado como o conteúdo padrão para todas as páginas criadas dentro deste item. Tenha em mente que isto só será usado se o criador da página tiver acesso à página de modelo escolhida.', + 'default_template_select' => 'Selecione uma página de modelo', + 'import' => 'Importar', + 'import_validate' => 'Validar Importação', + 'import_desc' => 'Importar livros, capítulos e páginas usando uma exportação ZIP portátil da mesma ou uma instância diferente. Selecione um arquivo ZIP para prosseguir. Após o carregamento e validação do arquivo, conseguirá configurar e confirmar a importação na próxima visualização.', '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', @@ -60,7 +60,7 @@ return [ '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_delete_desc' => 'Isto irá eliminar o arquivo ZIP de importação enviado e não pode ser desfeito.', 'import_errors' => 'Import Errors', 'import_errors_desc' => 'The follow errors occurred during the import attempt:', 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', @@ -170,8 +170,8 @@ return [ 'books_search_this' => 'Pesquisar neste livro', 'books_navigation' => 'Navegação do Livro', 'books_sort' => 'Ordenar Conteúdos do Livro', - '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_desc' => 'Mova capítulos e páginas de um livro para reorganizar o seu conteúdo. É possível acrescentar outros livros, o que permite uma movimentação fácil de capítulos e páginas entre livros. Opcionalmente, uma regra de organização automática pode ser definida para classificar automaticamente o conteúdo deste livro após alterações.', + 'books_sort_auto_sort' => '', 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', 'books_sort_named' => 'Ordenar Livro :bookName', 'books_sort_name' => 'Ordenar por Nome', From e033578feaa86dd0a3ac8dc4137bfec66a1baee7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 5 Apr 2026 22:43:15 +0100 Subject: [PATCH 07/69] Updated translator & dependency attribution before release v26.03.3 --- .github/translators.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/translators.txt b/.github/translators.txt index 97ab6c3fc..037887bcc 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -444,7 +444,7 @@ Irjan Olsen (Irch) :: Norwegian Bokmal Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic) Red (RedVortex) :: Hebrew xgrug :: Chinese Simplified -HrCalmar :: Danish +Calle Calmar (HrCalmar) :: Danish Avishay Rapp (AvishayRapp) :: Hebrew matthias4217 :: French Berke BOYLU2 (berkeboylu2) :: Turkish @@ -534,3 +534,6 @@ Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian Ilgiz Zigangirov (inov8) :: Russian Max Israelsson (Blezie) :: Swedish Skiddybison5924 (chris-devel0per) :: German +Veyilla Nightwhisper (Veyilla) :: German +João Barbosa (hypeedd) :: Portuguese +Abcdefg Hijklmn (collatek) :: Korean From c33853ed84bcefdd41fd12013451f14a69efc0d3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 8 Apr 2026 21:02:20 +0100 Subject: [PATCH 08/69] Maintenance: Updated NPM packages (#6090) * Maintenance: Updated NPM packages Includes typescript update to 6. Needed to update some typescript config to align with actual module environment used and built by esbuild. * Maintenance: Fixed testing issues after NPM dep version changes * Maintenance: Updated JS test workflow step version * Maintenance: Updated approach used for TS config in jest config --- .github/workflows/test-js.yml | 2 +- jest.config.ts | 14 +- package-lock.json | 1279 ++++++++++++++++++--------------- package.json | 24 +- tsconfig.json | 15 +- 5 files changed, 720 insertions(+), 614 deletions(-) diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 13f9a8a98..379f1ebfa 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -17,7 +17,7 @@ jobs: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install NPM deps run: npm ci diff --git a/jest.config.ts b/jest.config.ts index 53bfceb05..f7b5596de 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -5,7 +5,15 @@ import type {Config} from 'jest'; import {pathsToModuleNameMapper} from "ts-jest"; -import { compilerOptions } from './tsconfig.json'; +import fs from "node:fs"; + +const { compilerOptions } = JSON.parse(fs.readFileSync('./tsconfig.json', 'utf8')); +const compilerPaths = compilerOptions.paths as Record; +const cleanedPaths: Record = {}; +Object.keys(compilerPaths).forEach((key) => { + const paths = compilerPaths[key]; + cleanedPaths[key] = paths.map(p => p.replace('./', '')); +}); const config: Config = { // All imported modules in your tests should be mocked automatically @@ -98,7 +106,7 @@ const config: Config = { // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module moduleNameMapper: { 'lexical/shared/invariant': 'resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant', - ...pathsToModuleNameMapper(compilerOptions.paths), + ...pathsToModuleNameMapper(cleanedPaths), }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader @@ -111,7 +119,7 @@ const config: Config = { // notifyMode: "failure-change", // A preset that is used as a base for Jest's configuration - // preset: undefined, + preset: 'ts-jest', // Run tests from one or more projects // projects: undefined, diff --git a/package-lock.json b/package-lock.json index b6508f1e9..d239426ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@codemirror/commands": "^6.10.2", + "@codemirror/commands": "^6.10.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.5", @@ -13,15 +13,14 @@ "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-xml": "^6.1.0", - "@codemirror/language": "^6.12.2", + "@codemirror/language": "^6.12.3", "@codemirror/legacy-modes": "^6.5.2", - "@codemirror/state": "^6.5.4", + "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.39.16", + "@codemirror/view": "^6.41.0", "@lezer/highlight": "^1.2.3", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", - "@types/jest": "^30.0.0", "codemirror": "^6.0.2", "idb-keyval": "^6.2.2", "markdown-it": "^14.1.1", @@ -32,19 +31,20 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@lezer/generator": "^1.8.0", + "@types/jest": "^30.0.0", "@types/markdown-it": "^14.1.2", "@types/sortablejs": "^1.15.9", "chokidar-cli": "^3.0", - "esbuild": "^0.27.3", - "eslint": "^10.0.2", + "esbuild": "^0.28.0", + "eslint": "^10.2.0", "globals": "^17.4.0", - "jest": "^30.2.0", - "jest-environment-jsdom": "^30.2.0", + "jest": "^30.3.0", + "jest-environment-jsdom": "^30.3.0", "npm-run-all": "^4.1.5", - "sass": "^1.97.3", - "ts-jest": "^29.4.6", + "sass": "^1.99.0", + "ts-jest": "^29.4.9", "ts-node": "^10.9.2", - "typescript": "5.9.*" + "typescript": "6.0.*" } }, "node_modules/@asamuzakjp/css-color": { @@ -72,6 +72,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -223,6 +224,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -239,23 +241,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -575,13 +577,13 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz", - "integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==", + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.4.0", + "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } @@ -684,9 +686,9 @@ } }, "node_modules/@codemirror/language": { - "version": "6.12.2", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", - "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -729,9 +731,9 @@ } }, "node_modules/@codemirror/state": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", - "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" @@ -750,12 +752,12 @@ } }, "node_modules/@codemirror/view": { - "version": "6.39.16", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz", - "integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==", + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz", + "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==", "license": "MIT", "dependencies": { - "@codemirror/state": "^6.5.0", + "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" @@ -901,21 +903,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, @@ -924,9 +926,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -935,9 +937,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -952,9 +954,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -969,9 +971,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -986,9 +988,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -1003,9 +1005,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -1020,9 +1022,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -1037,9 +1039,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -1054,9 +1056,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -1071,9 +1073,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -1088,9 +1090,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -1105,9 +1107,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -1122,9 +1124,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -1139,9 +1141,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -1156,9 +1158,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -1173,9 +1175,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -1190,9 +1192,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -1207,9 +1209,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -1224,9 +1226,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -1241,9 +1243,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -1258,9 +1260,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -1275,9 +1277,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -1292,9 +1294,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -1309,9 +1311,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -1326,9 +1328,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -1343,9 +1345,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -1360,9 +1362,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -1419,37 +1421,37 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^10.2.4" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0" + "@eslint/core": "^1.2.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1481,9 +1483,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1491,13 +1493,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { @@ -1658,17 +1660,17 @@ } }, "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -1676,39 +1678,38 @@ } }, "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", + "@jest/console": "30.3.0", "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -1724,44 +1725,45 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/environment-jsdom-abstract": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.2.0.tgz", - "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.3.0.tgz", + "integrity": "sha512-0hNFs5N6We3DMCwobzI0ydhkY10sT1tZSC0AAiy+0g2Dt/qEWgrcV5BrMxPczhe41cxW4qm6X+jqZaUdpZIajA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/jsdom": "^21.1.7", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1777,23 +1779,24 @@ } }, "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" + "expect": "30.3.0", + "jest-snapshot": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0" @@ -1803,18 +1806,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1824,22 +1827,23 @@ "version": "30.1.0", "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1849,6 +1853,7 @@ "version": "30.0.1", "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1859,32 +1864,32 @@ } }, "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -1905,6 +1910,7 @@ "version": "30.0.5", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.34.0" @@ -1914,13 +1920,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -1945,14 +1951,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -1961,15 +1967,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", + "@jest/test-result": "30.3.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -1977,24 +1983,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", + "jest-util": "30.3.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -2004,9 +2009,10 @@ } }, "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "dev": true, "license": "MIT", "dependencies": { "@jest/pattern": "30.0.1", @@ -2072,15 +2078,15 @@ } }, "node_modules/@lezer/common": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", - "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", "license": "MIT" }, "node_modules/@lezer/css": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz", - "integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", + "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2333,6 +2339,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2354,6 +2363,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2375,6 +2387,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2396,6 +2411,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2417,6 +2435,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2438,6 +2459,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2515,9 +2539,9 @@ } }, "node_modules/@parcel/watcher/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "optional": true, @@ -2553,9 +2577,10 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, "license": "MIT" }, "node_modules/@sinonjs/commons": { @@ -2569,9 +2594,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.0.tgz", + "integrity": "sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2697,12 +2722,14 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" @@ -2712,6 +2739,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" @@ -2721,6 +2749,7 @@ "version": "30.0.0", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, "license": "MIT", "dependencies": { "expect": "^30.0.0", @@ -2772,9 +2801,10 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", - "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -2791,6 +2821,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, "license": "MIT" }, "node_modules/@types/tough-cookie": { @@ -2804,6 +2835,7 @@ "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, "license": "MIT", "dependencies": { "@types/yargs-parser": "*" @@ -2813,6 +2845,7 @@ "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, "license": "MIT" }, "node_modules/@ungap/structured-clone": { @@ -2928,6 +2961,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2942,6 +2978,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2956,6 +2995,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2970,6 +3012,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2984,6 +3029,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2998,6 +3046,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3012,6 +3063,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3026,6 +3080,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3187,6 +3244,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3295,16 +3353,16 @@ } }, "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.2.0", + "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", + "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -3337,9 +3395,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", "dev": true, "license": "MIT", "dependencies": { @@ -3377,13 +3435,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", + "babel-plugin-jest-hoist": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -3404,9 +3462,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3430,9 +3488,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3446,6 +3504,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3455,9 +3514,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -3475,11 +3534,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3589,9 +3648,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001777", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", - "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", "dev": true, "funding": [ { @@ -3613,6 +3672,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -3683,6 +3743,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, "funding": [ { "type": "github", @@ -3850,6 +3911,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3862,6 +3924,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -4145,9 +4208,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", "dev": true, "license": "ISC" }, @@ -4194,9 +4257,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -4330,9 +4393,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4343,32 +4406,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escalade": { @@ -4395,18 +4458,18 @@ } }, "node_modules/eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4415,9 +4478,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", + "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", + "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4428,7 +4491,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4451,9 +4514,9 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -4496,9 +4559,9 @@ } }, "node_modules/espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -4615,17 +4678,18 @@ } }, "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4679,6 +4743,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4719,9 +4784,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4974,9 +5039,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -5046,12 +5111,13 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5087,6 +5153,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5592,6 +5659,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5894,16 +5962,16 @@ } }, "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", "import-local": "^3.2.0", - "jest-cli": "30.2.0" + "jest-cli": "30.3.0" }, "bin": { "jest": "bin/jest.js" @@ -5921,14 +5989,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "p-limit": "^3.1.0" }, "engines": { @@ -5936,29 +6004,29 @@ } }, "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", "p-limit": "^3.1.0", - "pretty-format": "30.2.0", + "pretty-format": "30.3.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -5968,21 +6036,21 @@ } }, "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "yargs": "^17.7.2" }, "bin": { @@ -6108,34 +6176,33 @@ } }, "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", + "jest-circus": "30.3.0", "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", + "jest-environment-node": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "parse-json": "^5.2.0", - "pretty-format": "30.2.0", + "pretty-format": "30.3.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -6160,15 +6227,16 @@ } }, "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6188,33 +6256,31 @@ } }, "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" + "jest-util": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-jsdom": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz", - "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.3.0.tgz", + "integrity": "sha512-RLEOJy6ip1lpw0yqJ8tB3i88FC7VBz7i00Zvl2qF71IdxjS98gC9/0SPWYIBVXHm5hgCYK0PAlSlnHGGy9RoMg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/environment-jsdom-abstract": "30.2.0", - "@types/jsdom": "^21.1.7", - "@types/node": "*", + "@jest/environment": "30.3.0", + "@jest/environment-jsdom-abstract": "30.3.0", "jsdom": "^26.1.0" }, "engines": { @@ -6230,40 +6296,40 @@ } }, "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", "walker": "^1.0.8" }, "engines": { @@ -6273,48 +6339,63 @@ "fsevents": "^2.3.3" } }, + "node_modules/jest-haste-map/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -6322,15 +6403,29 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-message-util/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6358,24 +6453,25 @@ "version": "30.0.1", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -6384,46 +6480,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" + "jest-snapshot": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -6432,32 +6528,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -6466,9 +6562,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6477,20 +6573,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.2.0", + "expect": "30.3.0", "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -6512,26 +6608,28 @@ } }, "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6541,18 +6639,18 @@ } }, "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6572,19 +6670,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "string-length": "^4.0.2" }, "engines": { @@ -6592,15 +6690,15 @@ } }, "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -6628,6 +6726,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -6989,19 +7088,6 @@ "dev": true, "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -7013,13 +7099,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -7108,9 +7194,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "dev": true, "license": "MIT" }, @@ -7194,9 +7280,9 @@ "license": "MIT" }, "node_modules/npm-run-all/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -7653,12 +7739,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -7790,9 +7878,10 @@ } }, "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "30.0.5", @@ -7807,6 +7896,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -7855,6 +7945,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, "license": "MIT" }, "node_modules/read-pkg": { @@ -8060,14 +8151,14 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.97.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", - "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.0", - "immutable": "^5.0.2", + "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -8246,14 +8337,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -8318,6 +8409,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8416,6 +8508,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" @@ -8428,6 +8521,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8709,6 +8803,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -8776,9 +8871,9 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -8852,6 +8947,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -8887,19 +8983,19 @@ } }, "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.3", + "semver": "^7.7.4", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -8916,7 +9012,7 @@ "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { @@ -9132,9 +9228,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9188,6 +9284,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -9622,9 +9719,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 45c275863..80810fff1 100644 --- a/package.json +++ b/package.json @@ -20,22 +20,23 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@lezer/generator": "^1.8.0", + "@types/jest": "^30.0.0", "@types/markdown-it": "^14.1.2", "@types/sortablejs": "^1.15.9", "chokidar-cli": "^3.0", - "esbuild": "^0.27.3", - "eslint": "^10.0.2", + "esbuild": "^0.28.0", + "eslint": "^10.2.0", "globals": "^17.4.0", - "jest": "^30.2.0", - "jest-environment-jsdom": "^30.2.0", + "jest": "^30.3.0", + "jest-environment-jsdom": "^30.3.0", "npm-run-all": "^4.1.5", - "sass": "^1.97.3", - "ts-jest": "^29.4.6", + "sass": "^1.99.0", + "ts-jest": "^29.4.9", "ts-node": "^10.9.2", - "typescript": "5.9.*" + "typescript": "6.0.*" }, "dependencies": { - "@codemirror/commands": "^6.10.2", + "@codemirror/commands": "^6.10.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.5", @@ -43,15 +44,14 @@ "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-xml": "^6.1.0", - "@codemirror/language": "^6.12.2", + "@codemirror/language": "^6.12.3", "@codemirror/legacy-modes": "^6.5.2", - "@codemirror/state": "^6.5.4", + "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.39.16", + "@codemirror/view": "^6.41.0", "@lezer/highlight": "^1.2.3", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", - "@types/jest": "^30.0.0", "codemirror": "^6.0.2", "idb-keyval": "^6.2.2", "markdown-it": "^14.1.1", diff --git a/tsconfig.json b/tsconfig.json index dacaefea2..55b61674c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,15 +2,16 @@ "include": ["resources/js/**/*"], "exclude": ["resources/js/wysiwyg/lexical/yjs/*"], "compilerOptions": { - "target": "es2022", - "module": "commonjs", + "target": "es2023", + "module": "esnext", + "moduleResolution": "bundler", "rootDir": "./resources/js/", - "baseUrl": "./", + "types": ["jest", "node"], "paths": { - "@icons/*": ["resources/icons/*"], - "lexical": ["resources/js/wysiwyg/lexical/core/index.ts"], - "lexical/*": ["resources/js/wysiwyg/lexical/core/*"], - "@lexical/*": ["resources/js/wysiwyg/lexical/*"] + "@icons/*": ["./resources/icons/*"], + "lexical": ["./resources/js/wysiwyg/lexical/core/index.ts"], + "lexical/*": ["./resources/js/wysiwyg/lexical/core/*"], + "@lexical/*": ["./resources/js/wysiwyg/lexical/*"] }, "resolveJsonModule": true, "allowJs": true, From 5e78dc6ed54aeb6d3d2565334074286a0e249b78 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 8 Apr 2026 21:03:20 +0100 Subject: [PATCH 09/69] Maintenance: Updated PHPStan to Level 4 (#6085) --- .gitignore | 1 + app/Access/EmailConfirmationService.php | 2 ++ app/Access/LoginService.php | 2 +- app/Access/Mfa/MfaValue.php | 5 ++- app/Access/Oidc/OidcJwtSigningKey.php | 17 ++++------ app/Access/Oidc/OidcJwtWithClaims.php | 4 +-- app/Access/Oidc/OidcUserDetails.php | 2 +- app/Access/Saml2Service.php | 4 +-- app/Access/SocialAuthService.php | 4 +-- .../Notifications/NotificationManager.php | 14 ++++---- app/Api/ApiDocsGenerator.php | 10 ++++-- app/Api/ApiEntityListFormatter.php | 7 ++-- app/Api/ApiTokenGuard.php | 33 +++++-------------- .../Commands/AssignSortRuleCommand.php | 2 +- .../Commands/CopyShelfPermissionsCommand.php | 17 ++++++---- app/Entities/Models/Book.php | 2 +- app/Entities/Models/Entity.php | 1 + app/Entities/Models/Page.php | 2 +- app/Entities/Repos/PageRepo.php | 2 +- app/Entities/Tools/PageContent.php | 2 +- app/Entities/Tools/PermissionsUpdater.php | 2 +- app/Exports/ExportFormatter.php | 4 +-- app/Exports/ZipExports/ZipImportRunner.php | 14 ++++---- app/Exports/ZipExports/ZipReferenceParser.php | 4 --- app/Http/Controller.php | 2 +- app/Permissions/JointPermissionBuilder.php | 3 +- app/Search/SearchOptions.php | 12 +++---- app/Sorting/BookSorter.php | 3 +- app/Uploads/ImageRepo.php | 2 +- app/Uploads/UserAvatars.php | 2 +- app/Users/Models/User.php | 3 +- phpstan.neon.dist | 4 +-- tests/Api/SearchApiTest.php | 1 + .../CopyShelfPermissionsCommandTest.php | 18 ++++++++++ 34 files changed, 105 insertions(+), 102 deletions(-) diff --git a/.gitignore b/.gitignore index b545d161f..06a8723c5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /node_modules /.vscode /composer +/composer.phar /coverage Homestead.yaml .env diff --git a/app/Access/EmailConfirmationService.php b/app/Access/EmailConfirmationService.php index 1a5156d3e..e950c5504 100644 --- a/app/Access/EmailConfirmationService.php +++ b/app/Access/EmailConfirmationService.php @@ -5,6 +5,7 @@ namespace BookStack\Access; use BookStack\Access\Notifications\ConfirmEmailNotification; use BookStack\Exceptions\ConfirmationEmailException; use BookStack\Users\Models\User; +use Exception; class EmailConfirmationService extends UserTokenService { @@ -16,6 +17,7 @@ class EmailConfirmationService extends UserTokenService * Also removes any existing old ones. * * @throws ConfirmationEmailException + * @throws Exception */ public function sendConfirmation(User $user): void { diff --git a/app/Access/LoginService.php b/app/Access/LoginService.php index c81e95572..0c32b538f 100644 --- a/app/Access/LoginService.php +++ b/app/Access/LoginService.php @@ -71,7 +71,7 @@ class LoginService } $lastLoginDetails = $this->getLastLoginAttemptDetails(); - $this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false); + $this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember']); } /** diff --git a/app/Access/Mfa/MfaValue.php b/app/Access/Mfa/MfaValue.php index dd3e04618..b0f08e826 100644 --- a/app/Access/Mfa/MfaValue.php +++ b/app/Access/Mfa/MfaValue.php @@ -48,17 +48,16 @@ class MfaValue extends Model } /** - * Easily get the decrypted MFA value for the given user and method. + * Get the decrypted MFA value for the given user and method. */ public static function getValueForUser(User $user, string $method): ?string { - /** @var MfaValue $mfaVal */ $mfaVal = static::query() ->where('user_id', '=', $user->id) ->where('method', '=', $method) ->first(); - return $mfaVal ? $mfaVal->getValue() : null; + return $mfaVal?->getValue(); } /** diff --git a/app/Access/Oidc/OidcJwtSigningKey.php b/app/Access/Oidc/OidcJwtSigningKey.php index 3dab3e442..3edba12b3 100644 --- a/app/Access/Oidc/OidcJwtSigningKey.php +++ b/app/Access/Oidc/OidcJwtSigningKey.php @@ -9,10 +9,7 @@ use phpseclib3\Math\BigInteger; class OidcJwtSigningKey { - /** - * @var PublicKey - */ - protected $key; + protected PublicKey $key; /** * Can be created either from a JWK parameter array or local file path to load a certificate from. @@ -20,15 +17,13 @@ class OidcJwtSigningKey * 'file:///var/www/cert.pem' * ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...']. * - * @param array|string $jwkOrKeyPath - * * @throws OidcInvalidKeyException */ - public function __construct($jwkOrKeyPath) + public function __construct(array|string $jwkOrKeyPath) { if (is_array($jwkOrKeyPath)) { $this->loadFromJwkArray($jwkOrKeyPath); - } elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) { + } elseif (str_starts_with($jwkOrKeyPath, 'file://')) { $this->loadFromPath($jwkOrKeyPath); } else { throw new OidcInvalidKeyException('Unexpected type of key value provided'); @@ -38,7 +33,7 @@ class OidcJwtSigningKey /** * @throws OidcInvalidKeyException */ - protected function loadFromPath(string $path) + protected function loadFromPath(string $path): void { try { $key = PublicKeyLoader::load( @@ -58,7 +53,7 @@ class OidcJwtSigningKey /** * @throws OidcInvalidKeyException */ - protected function loadFromJwkArray(array $jwk) + protected function loadFromJwkArray(array $jwk): void { // 'alg' is optional for a JWK, but we will still attempt to validate if // it exists otherwise presume it will be compatible. @@ -82,7 +77,7 @@ class OidcJwtSigningKey throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected'); } - $n = strtr($jwk['n'] ?? '', '-_', '+/'); + $n = strtr($jwk['n'], '-_', '+/'); try { $key = PublicKeyLoader::load([ diff --git a/app/Access/Oidc/OidcJwtWithClaims.php b/app/Access/Oidc/OidcJwtWithClaims.php index 06c04d81e..9d7eeead1 100644 --- a/app/Access/Oidc/OidcJwtWithClaims.php +++ b/app/Access/Oidc/OidcJwtWithClaims.php @@ -102,12 +102,12 @@ class OidcJwtWithClaims implements ProvidesClaims protected function validateTokenStructure(): void { foreach (['header', 'payload'] as $prop) { - if (empty($this->$prop) || !is_array($this->$prop)) { + if (empty($this->$prop)) { throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token"); } } - if (empty($this->signature) || !is_string($this->signature)) { + if (empty($this->signature)) { throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token'); } } diff --git a/app/Access/Oidc/OidcUserDetails.php b/app/Access/Oidc/OidcUserDetails.php index 7a422a58d..b1736f97d 100644 --- a/app/Access/Oidc/OidcUserDetails.php +++ b/app/Access/Oidc/OidcUserDetails.php @@ -39,7 +39,7 @@ class OidcUserDetails ): void { $this->externalId = $claims->getClaim($idClaim) ?? $this->externalId; $this->email = $claims->getClaim('email') ?? $this->email; - $this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name; + $this->name = static::getUserDisplayName($displayNameClaims, $claims) ?: $this->name; $this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups; $this->picture = static::getPicture($claims) ?: $this->picture; } diff --git a/app/Access/Saml2Service.php b/app/Access/Saml2Service.php index 106a7a229..5572d2104 100644 --- a/app/Access/Saml2Service.php +++ b/app/Access/Saml2Service.php @@ -266,7 +266,7 @@ class Saml2Service /** * Extract the details of a user from a SAML response. * - * @return array{external_id: string, name: string, email: string, saml_id: string} + * @return array{external_id: string, name: string, email: string|null, saml_id: string} */ protected function getUserDetails(string $samlID, $samlAttributes): array { @@ -357,7 +357,7 @@ class Saml2Service ]); } - if ($userDetails['email'] === null) { + if (empty($userDetails['email'])) { throw new SamlException(trans('errors.saml_no_email_address')); } diff --git a/app/Access/SocialAuthService.php b/app/Access/SocialAuthService.php index c3c20587d..bdcfb45c8 100644 --- a/app/Access/SocialAuthService.php +++ b/app/Access/SocialAuthService.php @@ -117,14 +117,14 @@ class SocialAuthService } // When a user is logged in and the social account exists and is already linked to the current user. - if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) { + if ($isLoggedIn && $socialAccount->user->id === $currentUser->id) { session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver])); return redirect('/my-account/auth#social_accounts'); } // When a user is logged in, A social account exists but the users do not match. - if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) { + if ($isLoggedIn && $socialAccount->user->id != $currentUser->id) { session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver])); return redirect('/my-account/auth#social_accounts'); diff --git a/app/Activity/Notifications/NotificationManager.php b/app/Activity/Notifications/NotificationManager.php index 8a6c26ffb..38da2c552 100644 --- a/app/Activity/Notifications/NotificationManager.php +++ b/app/Activity/Notifications/NotificationManager.php @@ -15,14 +15,14 @@ use BookStack\Users\Models\User; class NotificationManager { /** - * @var class-string[] + * @var array[]> */ - protected array $handlers = []; + protected array $handlersByActivity = []; public function handle(Activity $activity, string|Loggable $detail, User $user): void { $activityType = $activity->type; - $handlersToRun = $this->handlers[$activityType] ?? []; + $handlersToRun = $this->handlersByActivity[$activityType] ?? []; foreach ($handlersToRun as $handlerClass) { /** @var NotificationHandler $handler */ $handler = new $handlerClass(); @@ -35,12 +35,12 @@ class NotificationManager */ public function registerHandler(string $activityType, string $handlerClass): void { - if (!isset($this->handlers[$activityType])) { - $this->handlers[$activityType] = []; + if (!isset($this->handlersByActivity[$activityType])) { + $this->handlersByActivity[$activityType] = []; } - if (!in_array($handlerClass, $this->handlers[$activityType])) { - $this->handlers[$activityType][] = $handlerClass; + if (!in_array($handlerClass, $this->handlersByActivity[$activityType])) { + $this->handlersByActivity[$activityType][] = $handlerClass; } } diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index eb8f5508c..a59cb8198 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -17,7 +17,14 @@ use ReflectionMethod; class ApiDocsGenerator { + /** + * @var array + */ protected array $reflectionClasses = []; + + /** + * @var array + */ protected array $controllerClasses = []; /** @@ -107,7 +114,6 @@ class ApiDocsGenerator */ protected function getBodyParamsFromClass(string $className, string $methodName): ?array { - /** @var ApiController $class */ $class = $this->controllerClasses[$className] ?? null; if ($class === null) { $class = app()->make($className); @@ -153,7 +159,7 @@ class ApiDocsGenerator $matches = []; preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches); - $text = implode(' ', $matches[1] ?? []); + $text = implode(' ', $matches[1]); return str_replace(' ', "\n", $text); } diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 3c94d96ee..23073bfc2 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -74,18 +74,21 @@ class ApiEntityListFormatter /** * Include parent book/chapter info in the formatted data. + * These functions are careful to not load the relation themselves, since they should + * have already been loaded in a more efficient manner, with permissions applied, by the time + * the parent fields are handled here. */ public function withParents(): self { $this->withField('book', function (Entity $entity) { - if ($entity instanceof BookChild && $entity->book) { + if ($entity instanceof BookChild && $entity->relationLoaded('book') && $entity->getRelationValue('book')) { return $entity->book->only(['id', 'name', 'slug']); } return null; }); $this->withField('chapter', function (Entity $entity) { - if ($entity instanceof Page && $entity->chapter) { + if ($entity instanceof Page && $entity->relationLoaded('chapter') && $entity->getRelationValue('chapter')) { return $entity->chapter->only(['id', 'name', 'slug']); } return null; diff --git a/app/Api/ApiTokenGuard.php b/app/Api/ApiTokenGuard.php index 9f4537b29..f1a3f0dc8 100644 --- a/app/Api/ApiTokenGuard.php +++ b/app/Api/ApiTokenGuard.php @@ -16,30 +16,15 @@ class ApiTokenGuard implements Guard { use GuardHelpers; - /** - * The request instance. - */ - protected $request; - - /** - * @var LoginService - */ - protected $loginService; - /** * The last auth exception thrown in this request. - * - * @var ApiAuthException */ - protected $lastAuthException; + protected ApiAuthException|null $lastAuthException = null; - /** - * ApiTokenGuard constructor. - */ - public function __construct(Request $request, LoginService $loginService) - { - $this->request = $request; - $this->loginService = $loginService; + public function __construct( + protected Request $request, + protected LoginService $loginService + ) { } /** @@ -67,7 +52,7 @@ class ApiTokenGuard implements Guard } /** - * Determine if current user is authenticated. If not, throw an exception. + * Determine if the current user is authenticated. If not, throw an exception. * * @throws ApiAuthException * @@ -121,7 +106,7 @@ class ApiTokenGuard implements Guard throw new ApiAuthException(trans('errors.api_no_authorization_found')); } - if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) { + if (!str_contains($authToken, ':') || !str_starts_with($authToken, 'Token ')) { throw new ApiAuthException(trans('errors.api_bad_authorization_format')); } } @@ -155,7 +140,7 @@ class ApiTokenGuard implements Guard /** * {@inheritdoc} */ - public function validate(array $credentials = []) + public function validate(array $credentials = []): bool { if (empty($credentials['id']) || empty($credentials['secret'])) { return false; @@ -175,7 +160,7 @@ class ApiTokenGuard implements Guard /** * "Log out" the currently authenticated user. */ - public function logout() + public function logout(): void { $this->user = null; } diff --git a/app/Console/Commands/AssignSortRuleCommand.php b/app/Console/Commands/AssignSortRuleCommand.php index c438d0783..f00df8383 100644 --- a/app/Console/Commands/AssignSortRuleCommand.php +++ b/app/Console/Commands/AssignSortRuleCommand.php @@ -32,7 +32,7 @@ class AssignSortRuleCommand extends Command */ public function handle(BookSorter $sorter): int { - $sortRuleId = intval($this->argument('sort-rule')) ?? 0; + $sortRuleId = intval($this->argument('sort-rule')); if ($sortRuleId === 0) { return $this->listSortRules(); } diff --git a/app/Console/Commands/CopyShelfPermissionsCommand.php b/app/Console/Commands/CopyShelfPermissionsCommand.php index c5e2d504e..1207621de 100644 --- a/app/Console/Commands/CopyShelfPermissionsCommand.php +++ b/app/Console/Commands/CopyShelfPermissionsCommand.php @@ -32,6 +32,7 @@ class CopyShelfPermissionsCommand extends Command { $shelfSlug = $this->option('slug'); $cascadeAll = $this->option('all'); + $noInteraction = boolval($this->option('no-interaction')); $shelves = null; if (!$cascadeAll && !$shelfSlug) { @@ -41,14 +42,16 @@ class CopyShelfPermissionsCommand extends Command } if ($cascadeAll) { - $continue = $this->confirm( - 'Permission settings for all shelves will be cascaded. ' . - 'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' . - 'Are you sure you want to proceed?' - ); + if (!$noInteraction) { + $continue = $this->confirm( + 'Permission settings for all shelves will be cascaded. ' . + 'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' . + 'Are you sure you want to proceed?', + ); - if (!$continue && !$this->hasOption('no-interaction')) { - return 0; + if (!$continue) { + return 0; + } } $shelves = $queries->start()->get(['id']); diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 1909dbd56..10f04695a 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -17,7 +17,7 @@ use Illuminate\Support\Collection; * * @property string $description * @property string $description_html - * @property int $image_id + * @property ?int $image_id * @property ?int $default_template_id * @property ?int $sort_rule_id * @property \Illuminate\Database\Eloquent\Collection $chapters diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 47e134626..27cfccaa8 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -479,6 +479,7 @@ abstract class Entity extends Model implements 'chapter' => new Chapter(), 'book' => new Book(), 'bookshelf' => new Bookshelf(), + default => throw new \InvalidArgumentException("Invalid entity type: {$type}"), }; } } diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index a1d3fc7b4..d3a392da6 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -23,7 +23,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; * @property bool $draft * @property int $revision_count * @property string $editor - * @property Chapter $chapter + * @property Chapter|null $chapter * @property Collection $attachments * @property Collection $revisions * @property PageRevision $currentRevision diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index bc590785d..375bf1d2b 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -60,7 +60,7 @@ class PageRepo $page->book_id = $parent->id; } - $defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get(); + $defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book->defaultTemplate()->get(); if ($defaultTemplate) { $page->forceFill([ 'html' => $defaultTemplate->html, diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 8d89a86cf..6a7649900 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -359,7 +359,7 @@ class PageContent { $contentHash = md5($html); $contentId = $this->page->id; - $contentTime = $this->page->updated_at?->timestamp ?? time(); + $contentTime = $this->page->updated_at->timestamp ?? time(); $appVersion = AppVersion::get(); $filterConfig = config('app.content_filtering') ?? ''; return "page-content-cache::{$filterConfig}::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}"; diff --git a/app/Entities/Tools/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php index fa9ae753c..f3165b603 100644 --- a/app/Entities/Tools/PermissionsUpdater.php +++ b/app/Entities/Tools/PermissionsUpdater.php @@ -47,7 +47,7 @@ class PermissionsUpdater { if (isset($data['role_permissions'])) { $entity->permissions()->where('role_id', '!=', 0)->delete(); - $rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'] ?? [], false); + $rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'], false); $entity->permissions()->createMany($rolePermissionData); } diff --git a/app/Exports/ExportFormatter.php b/app/Exports/ExportFormatter.php index c5973eace..6bf0a05ad 100644 --- a/app/Exports/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -208,7 +208,7 @@ class ExportFormatter preg_match_all("/\/i", $htmlContent, $imageTagsOutput); // Replace image src with base64 encoded image strings - if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) { + if (count($imageTagsOutput[0]) > 0) { foreach ($imageTagsOutput[0] as $index => $imgMatch) { $oldImgTagString = $imgMatch; $srcString = $imageTagsOutput[2][$index]; @@ -225,7 +225,7 @@ class ExportFormatter preg_match_all("/\/i", $htmlContent, $linksOutput); // Update relative links to be absolute, with instance url - if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) { + if (count($linksOutput[0]) > 0) { foreach ($linksOutput[0] as $index => $linkMatch) { $oldLinkString = $linkMatch; $srcString = $linksOutput[2][$index]; diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 382e4073e..9fa7dec3a 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -82,10 +82,8 @@ class ZipImportRunner $entity = $this->importBook($exportModel, $reader); } else if ($exportModel instanceof ZipExportChapter) { $entity = $this->importChapter($exportModel, $parent, $reader); - } else if ($exportModel instanceof ZipExportPage) { - $entity = $this->importPage($exportModel, $parent, $reader); } else { - throw new ZipImportException(['No importable data found in import data.']); + $entity = $this->importPage($exportModel, $parent, $reader); } $this->references->replaceReferences(); @@ -132,7 +130,7 @@ class ZipImportRunner 'name' => $exportBook->name, 'description_html' => $exportBook->description_html ?? '', 'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null, - 'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []), + 'tags' => $this->exportTagsToInputArray($exportBook->tags), ]); if ($book->coverInfo()->getImage()) { @@ -151,7 +149,7 @@ class ZipImportRunner foreach ($children as $child) { if ($child instanceof ZipExportChapter) { $this->importChapter($child, $book, $reader); - } else if ($child instanceof ZipExportPage) { + } else { $this->importPage($child, $book, $reader); } } @@ -166,7 +164,7 @@ class ZipImportRunner $chapter = $this->chapterRepo->create([ 'name' => $exportChapter->name, 'description_html' => $exportChapter->description_html ?? '', - 'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []), + 'tags' => $this->exportTagsToInputArray($exportChapter->tags), ], $parent); $exportPages = $exportChapter->pages; @@ -199,7 +197,7 @@ class ZipImportRunner 'name' => $exportPage->name, 'markdown' => $exportPage->markdown ?? '', 'html' => $exportPage->html ?? '', - 'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []), + 'tags' => $this->exportTagsToInputArray($exportPage->tags), ]); $this->references->addPage($page, $exportPage); @@ -302,7 +300,7 @@ class ZipImportRunner array_push($chapters, ...$exportModel->chapters); } else if ($exportModel instanceof ZipExportChapter) { $chapters[] = $exportModel; - } else if ($exportModel instanceof ZipExportPage) { + } else { $pages[] = $exportModel; } diff --git a/app/Exports/ZipExports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php index a6560e3f2..9bb069ab7 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -68,10 +68,6 @@ class ZipReferenceParser $matches = []; preg_match_all($referenceRegex, $content, $matches); - if (count($matches) < 3) { - return $content; - } - for ($i = 0; $i < count($matches[0]); $i++) { $referenceText = $matches[0][$i]; $type = strtolower($matches[1][$i]); diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 1a0f5932e..796505795 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -62,7 +62,7 @@ abstract class Controller extends BaseController */ protected function checkPermission(string|Permission $permission): void { - if (!user() || !user()->can($permission)) { + if (!user()->can($permission)) { $this->showPermissionError(); } } diff --git a/app/Permissions/JointPermissionBuilder.php b/app/Permissions/JointPermissionBuilder.php index 56b22ad16..94f18916d 100644 --- a/app/Permissions/JointPermissionBuilder.php +++ b/app/Permissions/JointPermissionBuilder.php @@ -61,8 +61,7 @@ class JointPermissionBuilder return; } - /** @var BookChild $entity */ - if ($entity->book) { + if ($entity instanceof BookChild) { $entities[] = $entity->book; } diff --git a/app/Search/SearchOptions.php b/app/Search/SearchOptions.php index 83af2d043..cfd068386 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -121,13 +121,11 @@ class SearchOptions foreach ($patterns as $termType => $pattern) { $matches = []; preg_match_all($pattern, $searchString, $matches); - if (count($matches) > 0) { - foreach ($matches[1] as $index => $value) { - $negated = str_starts_with($matches[0][$index], '-'); - $terms[$termType][] = $constructors[$termType]($value, $negated); - } - $searchString = preg_replace($pattern, '', $searchString); + foreach ($matches[1] as $index => $value) { + $negated = str_starts_with($matches[0][$index], '-'); + $terms[$termType][] = $constructors[$termType]($value, $negated); } + $searchString = preg_replace($pattern, '', $searchString); } // Unescape exacts and backslash escapes @@ -261,7 +259,7 @@ class SearchOptions $userFilters = ['updated_by', 'created_by', 'owned_by']; $unsupportedFilters = ['is_template', 'sort_by']; foreach ($this->filters->all() as $filter) { - if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $filter->value !== 'me') { + if (in_array($filter->getKey(), $userFilters, true) && $filter->value && $filter->value !== 'me') { $options[] = $filter; } else if (in_array($filter->getKey(), $unsupportedFilters, true)) { $options[] = $filter; diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index b4f93d47b..0862aaa88 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -125,9 +125,8 @@ class BookSorter */ protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void { - /** @var BookChild $model */ $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null; - if (!$model) { + if (!($model instanceof BookChild)) { return; } diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index a16b87bd7..e87e22b3a 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -91,7 +91,7 @@ class ImageRepo $parentFilter = function (Builder $query) use ($filterType, $contextPage) { if ($filterType === 'page') { $query->where('uploaded_to', '=', $contextPage->id); - } else if ($filterType === 'book') { + } else { $validPageIds = $contextPage->book->pages() ->scopes('visible') ->pluck('id') diff --git a/app/Uploads/UserAvatars.php b/app/Uploads/UserAvatars.php index 0cc640f22..8fcf63580 100644 --- a/app/Uploads/UserAvatars.php +++ b/app/Uploads/UserAvatars.php @@ -148,7 +148,7 @@ class UserAvatars $responseCount++; $isRedirect = ($response->getStatusCode() === 301 || $response->getStatusCode() === 302); $url = $response->getHeader('Location')[0] ?? ''; - } while ($responseCount < 3 && $isRedirect && is_string($url) && str_starts_with($url, 'http')); + } while ($responseCount < 3 && $isRedirect && str_starts_with($url, 'http')); if ($responseCount === 3) { throw new HttpFetchException("Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: {$url}"); diff --git a/app/Users/Models/User.php b/app/Users/Models/User.php index 50efdcdad..b9289a5a7 100644 --- a/app/Users/Models/User.php +++ b/app/Users/Models/User.php @@ -222,8 +222,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon public function getAvatar(int $size = 50): string { $default = url('/user_avatar.png'); - $imageId = $this->image_id; - if ($imageId === 0 || $imageId === '0' || $imageId === null) { + if ($this->image_id === 0) { return $default; } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 72189222f..bab28ea0e 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,11 +7,11 @@ parameters: - app # The level 8 is the highest level - level: 3 + level: 4 phpVersion: min: 80200 - max: 80400 + max: 80500 bootstrapFiles: - bootstrap/phpstan.php diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 517c5d8e4..5d0ce53ec 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -106,6 +106,7 @@ class SearchApiTest extends TestCase $this->permissions->setEntityPermissions($page, ['view'], [$editor->roles()->first()]); $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertOk(); $resp->assertJsonPath('data.0.id', $page->id); $resp->assertJsonPath('data.0.book.name', $book->name); $resp->assertJsonMissingPath('data.0.chapter'); diff --git a/tests/Commands/CopyShelfPermissionsCommandTest.php b/tests/Commands/CopyShelfPermissionsCommandTest.php index 5c21a2e34..d5f9677a2 100644 --- a/tests/Commands/CopyShelfPermissionsCommandTest.php +++ b/tests/Commands/CopyShelfPermissionsCommandTest.php @@ -2,6 +2,7 @@ namespace Tests\Commands; +use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use Tests\TestCase; @@ -61,4 +62,21 @@ class CopyShelfPermissionsCommandTest extends TestCase 'view' => true, 'update' => true, 'create' => false, 'delete' => false, ]); } + + public function test_copy_shelf_permissions_command_using_slug_without_interaction() + { + $shelf = $this->entities->shelfHasBooks(); + $editorRole = $this->users->editor()->roles()->first(); + /** @var Book $child */ + $child = $shelf->books()->first(); + $child->shelves()->where('id', '!=', $shelf->id)->delete(); + + $this->assertFalse($child->hasPermissions()); + + $this->permissions->setEntityPermissions($shelf, ['view', 'update'], [$editorRole]); + $this->artisan('bookstack:copy-shelf-permissions --all --no-interaction'); + + $child->refresh(); + $this->assertTrue($child->hasPermissions(), 'Child book should now be restricted'); + } } From 3d9d5fef51c29328aa071c906ccfda9dd77fed7b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Apr 2026 14:34:54 +0100 Subject: [PATCH 10/69] Theme Modules: Updated install command to handle nested folder Theme module ZIPs will now support their files being in a single nested directory within a ZIP, to support common ZIP structure approaches. Added test to cover. For #6066 --- app/Theming/ThemeModuleZip.php | 55 ++++++++++++++++++++- dev/docs/theme-system-modules.md | 1 + tests/Commands/InstallModuleCommandTest.php | 29 +++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/app/Theming/ThemeModuleZip.php b/app/Theming/ThemeModuleZip.php index 7029fa0c6..4785abadb 100644 --- a/app/Theming/ThemeModuleZip.php +++ b/app/Theming/ThemeModuleZip.php @@ -15,7 +15,41 @@ readonly class ThemeModuleZip { $zip = new ZipArchive(); $zip->open($this->path); - $zip->extractTo($destinationPath); + $prefix = $this->getZipContentPrefix($zip); + + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + $entryIsDir = str_ends_with($name, "/"); + if ($entryIsDir) { + continue; + } + + $stream = $zip->getStreamIndex($i); + + if ($prefix) { + if (!str_starts_with($name, $prefix) || $name === $prefix) { + continue; + } + $name = str_replace($prefix, '', $name); + } + + $targetPath = $destinationPath . DIRECTORY_SEPARATOR . $name; + $targetPathDir = dirname($targetPath); + if (!is_dir($targetPathDir)) { + $dirCreated = mkdir($targetPathDir, 0777, true); + if (!$dirCreated) { + throw new ThemeModuleException("Failed to create directory {$targetPathDir} when extracting module files"); + } + } + + $targetFile = fopen($targetPath, 'w'); + $written = stream_copy_to_stream($stream, $targetFile); + if (!$written) { + throw new ThemeModuleException("Failed to write to {$targetPath} when extracting module files"); + } + fclose($targetFile); + } + $zip->close(); } @@ -31,7 +65,8 @@ readonly class ThemeModuleZip throw new ThemeModuleException("Unable to open zip file at {$this->path}"); } - $moduleJsonText = $zip->getFromName('bookstack-module.json'); + $prefix = $this->getZipContentPrefix($zip); + $moduleJsonText = $zip->getFromName("{$prefix}bookstack-module.json"); $zip->close(); if ($moduleJsonText === false) { @@ -95,4 +130,20 @@ readonly class ThemeModuleZip return $totalSize; } + + protected function getZipContentPrefix(ZipArchive $zip): string + { + $index = $zip->locateName('bookstack-module.json', ZipArchive::FL_NODIR); + if ($index === false) { + return ''; + } + + $location = $zip->getNameIndex($index); + $pathParts = explode('/', $location); + if (count($pathParts) !== 2) { + return ''; + } + + return $pathParts[0] . '/'; + } } diff --git a/dev/docs/theme-system-modules.md b/dev/docs/theme-system-modules.md index 8aa9370ed..0086ac9c0 100644 --- a/dev/docs/theme-system-modules.md +++ b/dev/docs/theme-system-modules.md @@ -66,6 +66,7 @@ Here are some general best practices when it comes to creating modules: ### Distribution Format Modules are expected to be distributed as a compressed ZIP file, where the ZIP contents follow that of a module folder. +Contents may optionally be placed within a nested folder inside the ZIP. BookStack provides a `php artisan bookstack:install-module` command which allows modules to be installed from these ZIP files, either from a local path or from a web URL. Currently, there's a hardcoded total filesize limit of 50MB for module contents installed via this method. diff --git a/tests/Commands/InstallModuleCommandTest.php b/tests/Commands/InstallModuleCommandTest.php index 8ffc4ead3..ee8da1128 100644 --- a/tests/Commands/InstallModuleCommandTest.php +++ b/tests/Commands/InstallModuleCommandTest.php @@ -175,6 +175,35 @@ class InstallModuleCommandTest extends TestCase ->assertExitCode(1); } + public function test_module_zip_when_files_in_nested_directory() + { + $this->usingThemeFolder(function ($themeFolder) { + $zip = new ZipArchive(); + $zipFile = tempnam(sys_get_temp_dir(), 'bs-test-module'); + $zip->open($zipFile, ZipArchive::CREATE); + + $zip->addEmptyDir('mod'); + $zip->addFromString('mod/bookstack-module.json', json_encode($metadata ?? [ + 'name' => 'Test Module', + 'description' => 'A test module for BookStack', + 'version' => '1.0.0', + ])); + $zip->addFromString('mod/functions.php', 'addEmptyDir('mod/a'); + $zip->addFromString('mod/a/cat.txt', 'Meow'); + $zip->close(); + + $this->artisan('bookstack:install-module', ['location' => $zipFile]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->assertExitCode(0); + + $modulePath = glob(theme_path('modules/*'), GLOB_ONLYDIR)[0]; + $this->assertFileExists($modulePath . '/a/cat.txt'); + $contents = file_get_contents($modulePath . '/a/cat.txt'); + $this->assertEquals('Meow', $contents); + }); + } + public function test_local_module_install_without_active_theme_can_setup_theme_folder() { $zip = $this->getModuleZipPath(); From 5fbaab474058c9c90496649d53dd002e25419884 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Apr 2026 17:23:11 +0100 Subject: [PATCH 11/69] Theme modules: Allowed cross-origin redirects on download With a prompt to the user to confirm they trust the origin. For #6066 Added tests to cover. --- app/Console/Commands/InstallModuleCommand.php | 22 ++++++--- tests/Commands/InstallModuleCommandTest.php | 48 ++++++++++++++++++- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/app/Console/Commands/InstallModuleCommand.php b/app/Console/Commands/InstallModuleCommand.php index 20252525d..114bfb105 100644 --- a/app/Console/Commands/InstallModuleCommand.php +++ b/app/Console/Commands/InstallModuleCommand.php @@ -213,15 +213,23 @@ class InstallModuleCommand extends Command $redirectLocation = $resp->getHeaderLine('Location'); if ($redirectLocation) { $redirectUrl = parse_url($redirectLocation); - if ( - ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '') + $redirectOriginMatches = ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '') && ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '') - && ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '') - ) { - $currentLocation = $redirectLocation; - $redirectCount++; - continue; + && ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? ''); + + if (!$redirectOriginMatches) { + $redirectOrigin = ($redirectUrl['scheme'] ?? '') . '://' . ($redirectUrl['host'] ?? '') . (isset($redirectUrl['port']) ? ':' . $redirectUrl['port'] : ''); + $this->info("The download URL is redirecting to a different site: {$redirectOrigin}"); + $shouldContinue = $this->confirm("Do you trust downloading the module from this site?"); + if (!$shouldContinue) { + $this->error("Stopping module installation"); + return null; + } } + + $currentLocation = $redirectLocation; + $redirectCount++; + continue; } } diff --git a/tests/Commands/InstallModuleCommandTest.php b/tests/Commands/InstallModuleCommandTest.php index ee8da1128..c085c4907 100644 --- a/tests/Commands/InstallModuleCommandTest.php +++ b/tests/Commands/InstallModuleCommandTest.php @@ -96,18 +96,44 @@ class InstallModuleCommandTest extends TestCase }); } - public function test_remote_module_install_does_not_follow_redirects_to_different_origin() + public function test_remote_module_install_prompts_on_following_redirects_to_different_origin() { $this->usingThemeFolder(function () { $zip = $this->getModuleZipPath(); $http = $this->mockHttpClient([ new Response(302, ['Location' => 'http://example.com/a-test-module.zip']), + new Response(301, ['Location' => 'https://a.example.com:8080/a-test-module.zip']), new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) ]); $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('The download URL is redirecting to a different site: http://example.com') + ->expectsConfirmation('Do you trust downloading the module from this site?', 'yes') + ->expectsOutput('The download URL is redirecting to a different site: https://a.example.com:8080') + ->expectsConfirmation('Do you trust downloading the module from this site?', 'yes') + ->assertExitCode(0); + + $this->assertEquals(3, $http->requestCount()); + $this->assertEquals('https', $http->requestAt(0)->getUri()->getScheme()); + $this->assertEquals('http', $http->requestAt(1)->getUri()->getScheme()); + $this->assertEquals('a.example.com', $http->requestAt(2)->getUri()->getHost()); + }); + } + + public function test_remote_module_install_redirect_origin_prompt_rejection() + { + $this->usingThemeFolder(function () { + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'http://example.com/a-test-module.zip']), + new Response(301, ['Location' => 'https://a.example.com:8080/a-test-module.zip']), + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('The download URL is redirecting to a different site: http://example.com') + ->expectsConfirmation('Do you trust downloading the module from this site?', 'no') ->assertExitCode(1); $this->assertEquals(1, $http->requestCount()); @@ -115,6 +141,26 @@ class InstallModuleCommandTest extends TestCase }); } + public function test_remote_module_install_has_redirect_limit() + { + $this->usingThemeFolder(function () { + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'https://example.com/a-test-module.zip']), + new Response(302, ['Location' => 'https://example.com/b-test-module.zip']), + new Response(302, ['Location' => 'https://example.com/c-test-module.zip']), + new Response(302, ['Location' => 'https://example.com/d-test-module.zip']), + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('ERROR: Failed to download module from https://example.com/test-module.zip') + ->assertExitCode(1); + + $this->assertEquals(4, $http->requestCount()); + $this->assertEquals('/c-test-module.zip', $http->requestAt(3)->getUri()->getPath()); + }); + } + public function test_remote_module_install_download_failures_are_announced_to_user() { $this->usingThemeFolder(function () { From 684a94c4195e7d0c7b10268cd4a99717ad637710 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Apr 2026 18:49:34 +0100 Subject: [PATCH 12/69] Theme Modules: Prevented zip-slip in new module extraction method Updated the new (development only) approach which could result in zip-slip causing trouble. This adds path normalisation, and testing to cover. --- app/Theming/ThemeModuleManager.php | 9 ++++++++- app/Theming/ThemeModuleZip.php | 8 +++++++- tests/Commands/InstallModuleCommandTest.php | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/Theming/ThemeModuleManager.php b/app/Theming/ThemeModuleManager.php index 86362a2f3..ed33014eb 100644 --- a/app/Theming/ThemeModuleManager.php +++ b/app/Theming/ThemeModuleManager.php @@ -51,7 +51,14 @@ class ThemeModuleManager } $folderPath = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName; - $zip->extractTo($folderPath); + try { + $zip->extractTo($folderPath); + } catch (ThemeModuleException $exception) { + if (is_dir($folderPath)) { + $this->deleteDirectoryRecursively($folderPath); + } + throw new ThemeModuleException("Failed to load extract files from module ZIP with error: {$exception->getMessage()}"); + } $module = $this->loadFromFolder($folderName); if (!$module) { diff --git a/app/Theming/ThemeModuleZip.php b/app/Theming/ThemeModuleZip.php index 4785abadb..7e94074c8 100644 --- a/app/Theming/ThemeModuleZip.php +++ b/app/Theming/ThemeModuleZip.php @@ -2,6 +2,7 @@ namespace BookStack\Theming; +use BookStack\Util\FilePathNormalizer; use ZipArchive; readonly class ThemeModuleZip @@ -33,7 +34,12 @@ readonly class ThemeModuleZip $name = str_replace($prefix, '', $name); } - $targetPath = $destinationPath . DIRECTORY_SEPARATOR . $name; + try { + $targetPath = $destinationPath . DIRECTORY_SEPARATOR . FilePathNormalizer::normalize($name); + } catch (\Exception $exception) { + throw new ThemeModuleException("Bad file path found in module ZIP file: {$name}"); + } + $targetPathDir = dirname($targetPath); if (!is_dir($targetPathDir)) { $dirCreated = mkdir($targetPathDir, 0777, true); diff --git a/tests/Commands/InstallModuleCommandTest.php b/tests/Commands/InstallModuleCommandTest.php index c085c4907..e96fc02c1 100644 --- a/tests/Commands/InstallModuleCommandTest.php +++ b/tests/Commands/InstallModuleCommandTest.php @@ -250,6 +250,23 @@ class InstallModuleCommandTest extends TestCase }); } + public function test_module_install_negates_zip_slip() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(null, [ + '../parent.txt' => str_repeat('dog', 10) + ]); + + $expectedInstallPath = theme_path('modules/test-module'); + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput("ERROR: Failed to install module with error: Failed to load extract files from module ZIP with error: Bad file path found in module ZIP file: ../parent.txt") + ->assertExitCode(1); + + $this->assertDirectoryDoesNotExist($expectedInstallPath); + }); + } + public function test_local_module_install_without_active_theme_can_setup_theme_folder() { $zip = $this->getModuleZipPath(); From 4e3fa4822ff747d7a13c69c6d5d6a04899b33565 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Apr 2026 14:31:40 +0100 Subject: [PATCH 13/69] Sort Rules: Added creation hints to sort rule selection To help direct/indicate how rules can be created. For #5967 --- lang/en/entities.php | 1 + resources/views/books/sort.blade.php | 12 +++++++++++- .../views/settings/categories/sorting.blade.php | 7 +++++-- tests/Sorting/BookSortTest.php | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lang/en/entities.php b/lang/en/entities.php index 74c50be3b..5501d2bc2 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', diff --git a/resources/views/books/sort.blade.php b/resources/views/books/sort.blade.php index e090708b1..37a87d9ab 100644 --- a/resources/views/books/sort.blade.php +++ b/resources/views/books/sort.blade.php @@ -20,7 +20,12 @@

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

    -

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

    +
    +

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

    + @if(!userCan(\BookStack\Permissions\Permission::SettingsManage)) +

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

    + @endif +
    @php $autoSortVal = intval(old('auto-sort') ?? $book->sort_rule_id ?? 0); @@ -41,6 +46,11 @@ @endforeach + @if(userCan(\BookStack\Permissions\Permission::SettingsManage)) +

    + {{ trans('settings.sort_rule_create') }} +

    + @endif
    diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 0c9dc1f95..5678434e0 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -38,9 +38,9 @@
    -

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

    +

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

    -
    +
    +

    + {{ trans('settings.sort_rule_create') }} +

    diff --git a/tests/Sorting/BookSortTest.php b/tests/Sorting/BookSortTest.php index 7f31f9c27..33a10609a 100644 --- a/tests/Sorting/BookSortTest.php +++ b/tests/Sorting/BookSortTest.php @@ -271,6 +271,21 @@ class BookSortTest extends TestCase $this->withHtml($resp)->assertElementExists('select[name="auto-sort"] option[value="' . $sort->id . '"]'); } + public function test_auto_sort_rule_create_hint_shown_on_sort_page() + { + $book = $this->entities->book(); + $hintText = 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.'; + + // Admin users see link for creating new rule + $resp = $this->asAdmin()->get($book->getUrl('/sort')); + $this->withHtml($resp)->assertLinkExists(url('/settings/sorting/rules/new'), 'Create Sort Rule'); + $resp->assertDontSee($hintText); + + // Non-admin users see help text + $resp = $this->asEditor()->get($book->getUrl('/sort')); + $resp->assertSee($hintText); + } + public function test_auto_sort_option_submit_saves_to_book() { $sort = SortRule::factory()->create(); From c7e2b487c14133ef3fa62321fb226501a0f3fc88 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Apr 2026 15:17:31 +0100 Subject: [PATCH 14/69] Attachments: Aligned ZipExportAttachment link validation With controller routes. Don't consider this as a security issue, since the filtered URLs by that validation are very likely to be blocked by browser security or CSP, and there's a level of assumed privilege to the users that are able to create such attachments links already. Closes #6093 --- .../ZipExports/Models/ZipExportAttachment.php | 2 +- tests/Exports/ZipExportValidatorTest.php | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 97995738f..88c20e4d3 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -45,7 +45,7 @@ final class ZipExportAttachment extends ZipExportModel $rules = [ 'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')], 'name' => ['required', 'string', 'min:1'], - 'link' => ['required_without:file', 'nullable', 'string'], + 'link' => ['required_without:file', 'nullable', 'string', 'safe_url'], 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], ]; diff --git a/tests/Exports/ZipExportValidatorTest.php b/tests/Exports/ZipExportValidatorTest.php index c453ef294..e801705be 100644 --- a/tests/Exports/ZipExportValidatorTest.php +++ b/tests/Exports/ZipExportValidatorTest.php @@ -90,4 +90,29 @@ class ZipExportValidatorTest extends TestCase $this->assertEquals('The file needs to reference a file of type image/png,image/jpeg,image/gif,image/webp, found text/plain.', $results['page.images.0.file']); } + + public function test_page_link_attachments_cant_be_data_or_js() + { + $validateResultCountByLink = [ + 'data:text/html,

    hi

    ' => 1, + 'javascript:alert(\'hi\')' => 1, + 'mailto:email@example.com' => 0, + ]; + + foreach ($validateResultCountByLink as $link => $count) { + $validator = $this->getValidatorForData([ + 'page' => [ + 'id' => 4, + 'name' => 'My page', + 'markdown' => 'hello', + 'attachments' => [ + ['id' => 4, 'name' => 'Attachment A', 'link' => $link], + ], + ] + ]); + + $results = $validator->validate(); + $this->assertCount($count, $results); + } + } } From 4feb50e7ee06206b2ef037090673cabb70a17c73 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Apr 2026 15:29:00 +0100 Subject: [PATCH 15/69] Attachments: Aligned attachment validation a little more --- app/Exports/ZipExports/Models/ZipExportAttachment.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 88c20e4d3..161186687 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -45,7 +45,7 @@ final class ZipExportAttachment extends ZipExportModel $rules = [ 'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')], 'name' => ['required', 'string', 'min:1'], - 'link' => ['required_without:file', 'nullable', 'string', 'safe_url'], + 'link' => ['required_without:file', 'nullable', 'string', 'max:2000', 'safe_url'], 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], ]; From f14fc68b6697c203f4012c5bf537dcb49db85f87 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Apr 2026 18:26:00 +0100 Subject: [PATCH 16/69] API: Added new tags API endpoints --- app/Activity/Controllers/TagApiController.php | 51 +++++++++++++++++++ app/Activity/Controllers/TagController.php | 4 +- app/Activity/TagRepo.php | 34 ++++++++++--- app/Api/ListingResponseBuilder.php | 32 +++++++++--- app/Http/ApiController.php | 6 ++- routes/api.php | 4 ++ 6 files changed, 112 insertions(+), 19 deletions(-) create mode 100644 app/Activity/Controllers/TagApiController.php diff --git a/app/Activity/Controllers/TagApiController.php b/app/Activity/Controllers/TagApiController.php new file mode 100644 index 000000000..c1945ba5c --- /dev/null +++ b/app/Activity/Controllers/TagApiController.php @@ -0,0 +1,51 @@ +tagRepo + ->queryWithTotalsForApi(''); + + return $this->apiListingResponse($tagQuery, [ + 'name', 'values', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', + ], [], [ + 'name' + ]); + } + + /** + * Get a list of tag values used in the system, which have been used for the given tag name. + * You'll only see results based on tags applied to content you have access to. + * Only the value field can be used in filters. + */ + public function listValues(string $name): JsonResponse + { + $tagQuery = $this->tagRepo + ->queryWithTotalsForApi($name); + + return $this->apiListingResponse($tagQuery, [ + 'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', + ], [], [ + 'name', 'value', + ]); + } +} diff --git a/app/Activity/Controllers/TagController.php b/app/Activity/Controllers/TagController.php index 0af8835ca..723dc4ab4 100644 --- a/app/Activity/Controllers/TagController.php +++ b/app/Activity/Controllers/TagController.php @@ -24,9 +24,9 @@ class TagController extends Controller 'usages' => trans('entities.tags_usages'), ]); - $nameFilter = $request->get('name', ''); + $nameFilter = $request->input('name', ''); $tags = $this->tagRepo - ->queryWithTotals($listOptions, $nameFilter) + ->queryWithTotalsForList($listOptions, $nameFilter) ->paginate(50) ->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [ 'name' => $nameFilter, diff --git a/app/Activity/TagRepo.php b/app/Activity/TagRepo.php index 82c26b00e..3e8d5545a 100644 --- a/app/Activity/TagRepo.php +++ b/app/Activity/TagRepo.php @@ -18,9 +18,10 @@ class TagRepo } /** - * Start a query against all tags in the system. + * Start a query against all tags in the system, with total counts for their usage, + * suitable for a system interface list with listing options. */ - public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder + public function queryWithTotalsForList(SimpleListOptions $listOptions, string $nameFilter): Builder { $searchTerm = $listOptions->getSearch(); $sort = $listOptions->getSort(); @@ -28,17 +29,34 @@ class TagRepo $sort = 'value'; } + $query = $this->baseQueryWithTotals($nameFilter, $searchTerm) + ->orderBy($sort, $listOptions->getOrder()); + + return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + } + + /** + * Start a query against all tags in the system, with total counts for their usage, + * which can be used via the API. + */ + public function queryWithTotalsForApi(string $nameFilter): Builder + { + $query = $this->baseQueryWithTotals($nameFilter, ''); + return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + } + + protected function baseQueryWithTotals(string $nameFilter, string $searchTerm): Builder + { $query = Tag::query() ->select([ 'name', ($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'), DB::raw('COUNT(id) as usages'), - DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'), - DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'), - DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'), - DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'), + DB::raw('CAST(SUM(IF(entity_type = \'page\', 1, 0)) as UNSIGNED) as page_count'), + DB::raw('CAST(SUM(IF(entity_type = \'chapter\', 1, 0)) as UNSIGNED) as chapter_count'), + DB::raw('CAST(SUM(IF(entity_type = \'book\', 1, 0)) as UNSIGNED) as book_count'), + DB::raw('CAST(SUM(IF(entity_type = \'bookshelf\', 1, 0)) as UNSIGNED) as shelf_count'), ]) - ->orderBy($sort, $listOptions->getOrder()) ->whereHas('entity'); if ($nameFilter) { @@ -57,7 +75,7 @@ class TagRepo }); } - return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + return $query; } /** diff --git a/app/Api/ListingResponseBuilder.php b/app/Api/ListingResponseBuilder.php index 44117bad9..6b9cfdd7d 100644 --- a/app/Api/ListingResponseBuilder.php +++ b/app/Api/ListingResponseBuilder.php @@ -18,6 +18,13 @@ class ListingResponseBuilder */ protected array $fields; + /** + * Which fields are filterable. + * When null, the $fields above are used instead (Allow all fields). + * @var string[]|null + */ + protected array|null $filterableFields = null; + /** * @var array */ @@ -54,7 +61,7 @@ class ListingResponseBuilder { $filteredQuery = $this->filterQuery($this->query); - $total = $filteredQuery->count(); + $total = $filteredQuery->getCountForPagination(); $data = $this->fetchData($filteredQuery)->each(function ($model) { foreach ($this->resultModifiers as $modifier) { $modifier($model); @@ -77,6 +84,14 @@ class ListingResponseBuilder $this->resultModifiers[] = $modifier; } + /** + * Limit filtering to just the given set of fields. + */ + public function setFilterableFields(array $fields): void + { + $this->filterableFields = $fields; + } + /** * Fetch the data to return within the response. */ @@ -94,7 +109,7 @@ class ListingResponseBuilder protected function filterQuery(Builder $query): Builder { $query = clone $query; - $requestFilters = $this->request->get('filter', []); + $requestFilters = $this->request->input('filter', []); if (!is_array($requestFilters)) { return $query; } @@ -114,10 +129,11 @@ class ListingResponseBuilder protected function requestFilterToQueryFilter($fieldKey, $value): ?array { $splitKey = explode(':', $fieldKey); - $field = $splitKey[0]; + $field = strtolower($splitKey[0]); $filterOperator = $splitKey[1] ?? 'eq'; - if (!in_array($field, $this->fields)) { + $filterFields = $this->filterableFields ?? $this->fields; + if (!in_array($field, $filterFields)) { return null; } @@ -140,8 +156,8 @@ class ListingResponseBuilder $defaultSortName = $this->fields[0]; $direction = 'asc'; - $sort = $this->request->get('sort', ''); - if (strpos($sort, '-') === 0) { + $sort = $this->request->input('sort', ''); + if (str_starts_with($sort, '-')) { $direction = 'desc'; } @@ -160,9 +176,9 @@ class ListingResponseBuilder protected function countAndOffsetQuery(Builder $query): Builder { $query = clone $query; - $offset = max(0, $this->request->get('offset', 0)); + $offset = max(0, $this->request->input('offset', 0)); $maxCount = config('api.max_item_count'); - $count = $this->request->get('count', config('api.default_item_count')); + $count = $this->request->input('count', config('api.default_item_count')); $count = max(min($maxCount, $count), 1); return $query->skip($offset)->take($count); diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index 8c0f206d0..f1b74783f 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -20,10 +20,14 @@ abstract class ApiController extends Controller * Provide a paginated listing JSON response in a standard format * taking into account any pagination parameters passed by the user. */ - protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse + protected function apiListingResponse(Builder $query, array $fields, array $modifiers = [], array $filterableFields = []): JsonResponse { $listing = new ListingResponseBuilder($query, request(), $fields); + if (count($filterableFields) > 0) { + $listing->setFilterableFields($filterableFields); + } + foreach ($modifiers as $modifier) { $listing->modifyResults($modifier); } diff --git a/routes/api.php b/routes/api.php index 308a95d8c..9f45cefb9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,7 @@ */ use BookStack\Activity\Controllers as ActivityControllers; +use BookStack\Activity\Controllers\TagApiController; use BookStack\Api\ApiDocsController; use BookStack\App\SystemApiController; use BookStack\Entities\Controllers as EntityControllers; @@ -109,6 +110,9 @@ Route::get('search', [SearchApiController::class, 'all']); Route::get('system', [SystemApiController::class, 'read']); +Route::get('tags/names', [TagApiController::class, 'listNames']); +Route::get('tags/name/{name}/values', [TagApiController::class, 'listValues']); + Route::get('users', [UserApiController::class, 'list']); Route::post('users', [UserApiController::class, 'create']); Route::get('users/{id}', [UserApiController::class, 'read']); From 1c1ad1d1b739d2558d31478610b1ca4af553f3eb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Apr 2026 20:45:18 +0100 Subject: [PATCH 17/69] Tags API: Reviewed docs and added examples --- app/Activity/Controllers/TagApiController.php | 10 ++++-- app/Api/ApiDocsGenerator.php | 7 ++-- dev/api/responses/tags-list-names.json | 32 +++++++++++++++++++ dev/api/responses/tags-list-values.json | 32 +++++++++++++++++++ .../views/api-docs/parts/endpoint.blade.php | 2 +- .../api-docs/parts/getting-started.blade.php | 2 +- 6 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 dev/api/responses/tags-list-names.json create mode 100644 dev/api/responses/tags-list-values.json diff --git a/app/Activity/Controllers/TagApiController.php b/app/Activity/Controllers/TagApiController.php index c1945ba5c..1fdffa007 100644 --- a/app/Activity/Controllers/TagApiController.php +++ b/app/Activity/Controllers/TagApiController.php @@ -8,6 +8,12 @@ use BookStack\Activity\TagRepo; use BookStack\Http\ApiController; use Illuminate\Http\JsonResponse; +/** + * Endpoints to query data about tags in the system. + * You'll only see results based on tags applied to content you have access to. + * There are no general create/update/delete endpoints here since tags do not exist + * by themselves, they are managed via the items they are assigned to. + */ class TagApiController extends ApiController { public function __construct( @@ -17,7 +23,6 @@ class TagApiController extends ApiController /** * Get a list of tag names used in the system. - * You'll only see results based on tags applied to content you have access to. * Only the name field can be used in filters. */ public function listNames(): JsonResponse @@ -33,8 +38,7 @@ class TagApiController extends ApiController } /** - * Get a list of tag values used in the system, which have been used for the given tag name. - * You'll only see results based on tags applied to content you have access to. + * Get a list of tag values, which have been set for the given tag name. * Only the value field can be used in filters. */ public function listValues(string $name): JsonResponse diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index a59cb8198..53cb2890a 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -195,11 +195,12 @@ class ApiDocsGenerator protected function getFlatApiRoutes(): Collection { return collect(Route::getRoutes()->getRoutes())->filter(function ($route) { - return strpos($route->uri, 'api/') === 0; + return str_starts_with($route->uri, 'api/'); })->map(function ($route) { [$controller, $controllerMethod] = explode('@', $route->action['uses']); $baseModelName = explode('.', explode('/', $route->uri)[1])[0]; - $shortName = $baseModelName . '-' . $controllerMethod; + $controllerMethodKebab = Str::kebab($controllerMethod); + $shortName = $baseModelName . '-' . $controllerMethodKebab; return [ 'name' => $shortName, @@ -207,7 +208,7 @@ class ApiDocsGenerator 'method' => $route->methods[0], 'controller' => $controller, 'controller_method' => $controllerMethod, - 'controller_method_kebab' => Str::kebab($controllerMethod), + 'controller_method_kebab' => $controllerMethodKebab, 'base_model' => $baseModelName, ]; }); diff --git a/dev/api/responses/tags-list-names.json b/dev/api/responses/tags-list-names.json new file mode 100644 index 000000000..c0c8e7b22 --- /dev/null +++ b/dev/api/responses/tags-list-names.json @@ -0,0 +1,32 @@ +{ + "data": [ + { + "name": "Category", + "values": 8, + "usages": 184, + "page_count": 3, + "chapter_count": 8, + "book_count": 171, + "shelf_count": 2 + }, + { + "name": "Review Due", + "values": 2, + "usages": 2, + "page_count": 1, + "chapter_count": 0, + "book_count": 1, + "shelf_count": 0 + }, + { + "name": "Type", + "values": 2, + "usages": 2, + "page_count": 0, + "chapter_count": 1, + "book_count": 1, + "shelf_count": 0 + } + ], + "total": 3 +} \ No newline at end of file diff --git a/dev/api/responses/tags-list-values.json b/dev/api/responses/tags-list-values.json new file mode 100644 index 000000000..37926b846 --- /dev/null +++ b/dev/api/responses/tags-list-values.json @@ -0,0 +1,32 @@ +{ + "data": [ + { + "name": "Category", + "value": "Cool Stuff", + "usages": 3, + "page_count": 1, + "chapter_count": 0, + "book_count": 2, + "shelf_count": 0 + }, + { + "name": "Category", + "value": "Top Content", + "usages": 168, + "page_count": 0, + "chapter_count": 3, + "book_count": 165, + "shelf_count": 0 + }, + { + "name": "Category", + "value": "Learning", + "usages": 2, + "page_count": 0, + "chapter_count": 0, + "book_count": 0, + "shelf_count": 2 + } + ], + "total": 3 +} \ No newline at end of file diff --git a/resources/views/api-docs/parts/endpoint.blade.php b/resources/views/api-docs/parts/endpoint.blade.php index 024a5ecdf..543ef092e 100644 --- a/resources/views/api-docs/parts/endpoint.blade.php +++ b/resources/views/api-docs/parts/endpoint.blade.php @@ -1,7 +1,7 @@
    {{ $endpoint['method'] }}
    - @if($endpoint['controller_method_kebab'] === 'list') + @if(str_starts_with($endpoint['controller_method_kebab'], 'list') && !str_contains($endpoint['uri'], '{')) {{ url($endpoint['uri']) }} @else {{ url($endpoint['uri']) }} diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index 663389047..ebe3838ef 100644 --- a/resources/views/api-docs/parts/getting-started.blade.php +++ b/resources/views/api-docs/parts/getting-started.blade.php @@ -2,7 +2,7 @@

    This documentation covers use of the REST API.
    - Examples of API usage, in a variety of programming languages, can be found in the BookStack api-scripts repo on GitHub. + Examples of API usage, in a variety of programming languages, can be found in the BookStack api-scripts repo on Codeberg.

    Some alternative options for extension and customization can be found below: From 346dc27979a05a7b82e7fef5f32c00be2578f77f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 14 Apr 2026 11:31:34 +0100 Subject: [PATCH 18/69] API: Added testing to cover tags API endpoints --- tests/Api/TagsApiTest.php | 109 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/Api/TagsApiTest.php diff --git a/tests/Api/TagsApiTest.php b/tests/Api/TagsApiTest.php new file mode 100644 index 000000000..baf7b0859 --- /dev/null +++ b/tests/Api/TagsApiTest.php @@ -0,0 +1,109 @@ + 'MyGreatApiTag', 'value' => 'cat']; + $pagesToTag = Page::query()->take(10)->get(); + $booksToTag = Book::query()->take(3)->get(); + $chaptersToTag = Chapter::query()->take(5)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag($tagInfo))); + $booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag($tagInfo))); + $chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag($tagInfo))); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson([ + 'data' => [ + [ + 'name' => 'MyGreatApiTag', + 'values' => 1, + 'usages' => 18, + 'page_count' => 10, + 'book_count' => 3, + 'chapter_count' => 5, + 'shelf_count' => 0, + ] + ], + 'total' => 1, + ]); + } + + public function test_list_names_is_limited_by_permission_visibility(): void + { + $pagesToTag = Page::query()->take(10)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id]))); + + $this->permissions->disableEntityInheritedPermissions($pagesToTag[3]); + $this->permissions->disableEntityInheritedPermissions($pagesToTag[6]); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson([ + 'data' => [ + [ + 'name' => 'MyGreatApiTag', + 'values' => 8, + 'usages' => 8, + 'page_count' => 8, + 'book_count' => 0, + 'chapter_count' => 0, + 'shelf_count' => 0, + ] + ], + 'total' => 1, + ]); + } + + public function test_list_values_returns_values_for_set_tag() + { + $pagesToTag = Page::query()->take(10)->get(); + $booksToTag = Book::query()->take(3)->get(); + $chaptersToTag = Chapter::query()->take(5)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-page' . $page->id]))); + $booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-book' . $book->id]))); + $chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-chapter' . $chapter->id]))); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/name/MyValueApiTag/values'); + + $resp->assertStatus(200); + $resp->assertJson(['total' => 18]); + $resp->assertJsonFragment([ + [ + 'name' => 'MyValueApiTag', + 'value' => 'tag-page' . $pagesToTag[0]->id, + 'usages' => 1, + 'page_count' => 1, + 'book_count' => 0, + 'chapter_count' => 0, + 'shelf_count' => 0, + ] + ]); + } + + public function test_list_values_is_limited_by_permission_visibility(): void + { + $pagesToTag = Page::query()->take(10)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id]))); + + $this->permissions->disableEntityInheritedPermissions($pagesToTag[3]); + $this->permissions->disableEntityInheritedPermissions($pagesToTag[6]); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/name/MyGreatApiTag/values'); + $resp->assertStatus(200); + $resp->assertJson(['total' => 8]); + $resp->assertJsonMissing(['value' => 'cat' . $pagesToTag[3]->id]); + } +} From 208629ee1fce280c31f9baf30a6ed79c3cd17df0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 14 Apr 2026 12:03:29 +0100 Subject: [PATCH 19/69] API: Some changes to tag API endpoints - Updated tag values endpoint to use query param instead of path argument, so a better range of values can be provided (including those with slashes). - Updated image gallery example request to align with docs use changes. --- app/Activity/Controllers/TagApiController.php | 23 +++++++++++++++---- ...p => image-gallery-read-data-for-url.http} | 0 dev/api/requests/tags-list-values.http | 1 + routes/api.php | 2 +- tests/Api/TagsApiTest.php | 4 ++-- 5 files changed, 22 insertions(+), 8 deletions(-) rename dev/api/requests/{image-gallery-readDataForUrl.http => image-gallery-read-data-for-url.http} (100%) create mode 100644 dev/api/requests/tags-list-values.http diff --git a/app/Activity/Controllers/TagApiController.php b/app/Activity/Controllers/TagApiController.php index 1fdffa007..f5c5e95d4 100644 --- a/app/Activity/Controllers/TagApiController.php +++ b/app/Activity/Controllers/TagApiController.php @@ -7,6 +7,7 @@ namespace BookStack\Activity\Controllers; use BookStack\Activity\TagRepo; use BookStack\Http\ApiController; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; /** * Endpoints to query data about tags in the system. @@ -21,6 +22,15 @@ class TagApiController extends ApiController ) { } + protected function rules(): array + { + return [ + 'listValues' => [ + 'name' => ['required', 'string'], + ], + ]; + } + /** * Get a list of tag names used in the system. * Only the name field can be used in filters. @@ -38,18 +48,21 @@ class TagApiController extends ApiController } /** - * Get a list of tag values, which have been set for the given tag name. + * Get a list of tag values, which have been set for the given tag name, + * which must be provided as a query parameter on the request. * Only the value field can be used in filters. */ - public function listValues(string $name): JsonResponse + public function listValues(Request $request): JsonResponse { - $tagQuery = $this->tagRepo - ->queryWithTotalsForApi($name); + $data = $this->validate($request, $this->rules()['listValues']); + $name = $data['name']; + + $tagQuery = $this->tagRepo->queryWithTotalsForApi($name); return $this->apiListingResponse($tagQuery, [ 'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', ], [], [ - 'name', 'value', + 'value', ]); } } diff --git a/dev/api/requests/image-gallery-readDataForUrl.http b/dev/api/requests/image-gallery-read-data-for-url.http similarity index 100% rename from dev/api/requests/image-gallery-readDataForUrl.http rename to dev/api/requests/image-gallery-read-data-for-url.http diff --git a/dev/api/requests/tags-list-values.http b/dev/api/requests/tags-list-values.http new file mode 100644 index 000000000..6dd3f49fc --- /dev/null +++ b/dev/api/requests/tags-list-values.http @@ -0,0 +1 @@ +GET /api/tags/values-for-name?name=Category diff --git a/routes/api.php b/routes/api.php index 9f45cefb9..5a9df3cc4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -111,7 +111,7 @@ Route::get('search', [SearchApiController::class, 'all']); Route::get('system', [SystemApiController::class, 'read']); Route::get('tags/names', [TagApiController::class, 'listNames']); -Route::get('tags/name/{name}/values', [TagApiController::class, 'listValues']); +Route::get('tags/values-for-name', [TagApiController::class, 'listValues']); Route::get('users', [UserApiController::class, 'list']); Route::post('users', [UserApiController::class, 'create']); diff --git a/tests/Api/TagsApiTest.php b/tests/Api/TagsApiTest.php index baf7b0859..a079fa639 100644 --- a/tests/Api/TagsApiTest.php +++ b/tests/Api/TagsApiTest.php @@ -76,7 +76,7 @@ class TagsApiTest extends TestCase $booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-book' . $book->id]))); $chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-chapter' . $chapter->id]))); - $resp = $this->actingAsApiEditor()->getJson('api/tags/name/MyValueApiTag/values'); + $resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyValueApiTag'); $resp->assertStatus(200); $resp->assertJson(['total' => 18]); @@ -101,7 +101,7 @@ class TagsApiTest extends TestCase $this->permissions->disableEntityInheritedPermissions($pagesToTag[3]); $this->permissions->disableEntityInheritedPermissions($pagesToTag[6]); - $resp = $this->actingAsApiEditor()->getJson('api/tags/name/MyGreatApiTag/values'); + $resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyGreatApiTag'); $resp->assertStatus(200); $resp->assertJson(['total' => 8]); $resp->assertJsonMissing(['value' => 'cat' . $pagesToTag[3]->id]); From 18364d1e6e235ab129a03e77279201436bcd954a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 16 Apr 2026 11:11:06 +0100 Subject: [PATCH 20/69] WYSIWYG: Added inline code support to minimal editor Used for comments and descriptions. Also updated shortcut handling that we're not registering shortcuts for edits which can't use the related formatting types. For #6003 --- app/Util/HtmlDescriptionFilter.php | 1 + resources/js/wysiwyg/index.ts | 6 +- resources/js/wysiwyg/services/shortcuts.ts | 59 +++++++++++--------- resources/js/wysiwyg/ui/defaults/toolbars.ts | 1 + tests/Entity/BookTest.php | 4 +- 5 files changed, 40 insertions(+), 31 deletions(-) diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php index 1baa11ffc..ba1454603 100644 --- a/app/Util/HtmlDescriptionFilter.php +++ b/app/Util/HtmlDescriptionFilter.php @@ -27,6 +27,7 @@ class HtmlDescriptionFilter 'span' => [], 'em' => [], 'br' => [], + 'code' => [], ]; public static function filterFromString(string $html): string diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 01964b066..dc0ea211f 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -59,7 +59,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), - registerShortcuts(context), + registerShortcuts(context, true), registerKeyboardHandling(context), registerMouseHandling(context), registerSelectionHandling(context), @@ -123,7 +123,7 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s const editorTeardown = mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), - registerShortcuts(context), + registerShortcuts(context, false), registerAutoLinks(editor), ); @@ -157,7 +157,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent: const editorTeardown = mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), - registerShortcuts(context), + registerShortcuts(context, false), registerAutoLinks(editor), registerMentions(context), ); diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index c4be0f3cf..00abe0c6d 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -38,29 +38,9 @@ type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boole * List of action functions by their shortcut combo. * We use "meta" as an abstraction for ctrl/cmd depending on platform. */ -const actionsByKeys: Record = { - 'meta+s': () => { - window.$events.emit('editor-save-draft'); - return true; - }, - 'meta+enter': () => { - window.$events.emit('editor-save-page'); - return true; - }, - 'meta+1': (editor, context) => headerHandler(context, 'h2'), - 'meta+2': (editor, context) => headerHandler(context, 'h3'), - 'meta+3': (editor, context) => headerHandler(context, 'h4'), - 'meta+4': (editor, context) => headerHandler(context, 'h5'), - 'meta+5': wrapFormatAction(toggleSelectionAsParagraph), - 'meta+d': wrapFormatAction(toggleSelectionAsParagraph), - 'meta+6': wrapFormatAction(toggleSelectionAsBlockquote), - 'meta+q': wrapFormatAction(toggleSelectionAsBlockquote), - 'meta+7': wrapFormatAction(formatCodeBlock), - 'meta+e': wrapFormatAction(formatCodeBlock), +const baseActionsByKeys: Record = { 'meta+8': toggleInlineCode, 'meta+shift+e': toggleInlineCode, - 'meta+9': wrapFormatAction(cycleSelectionCalloutFormats), - 'meta+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')), 'meta+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')), 'meta+k': (editor, context) => { @@ -87,12 +67,39 @@ const actionsByKeys: Record = { }, }; -function createKeyDownListener(context: EditorUiContext): (e: KeyboardEvent) => void { +/** + * An extended set of the above, used for fuller-featured editors with heavier block-level formatting. + */ +const extendedActionsByKeys: Record = { + ...baseActionsByKeys, + 'meta+s': () => { + window.$events.emit('editor-save-draft'); + return true; + }, + 'meta+enter': () => { + window.$events.emit('editor-save-page'); + return true; + }, + 'meta+1': (editor, context) => headerHandler(context, 'h2'), + 'meta+2': (editor, context) => headerHandler(context, 'h3'), + 'meta+3': (editor, context) => headerHandler(context, 'h4'), + 'meta+4': (editor, context) => headerHandler(context, 'h5'), + 'meta+5': wrapFormatAction(toggleSelectionAsParagraph), + 'meta+d': wrapFormatAction(toggleSelectionAsParagraph), + 'meta+6': wrapFormatAction(toggleSelectionAsBlockquote), + 'meta+7': wrapFormatAction(formatCodeBlock), + 'meta+e': wrapFormatAction(formatCodeBlock), + 'meta+q': wrapFormatAction(toggleSelectionAsBlockquote), + 'meta+9': wrapFormatAction(cycleSelectionCalloutFormats), +}; + +function createKeyDownListener(context: EditorUiContext, useExtended: boolean): (e: KeyboardEvent) => void { + const keySetToUse = useExtended ? extendedActionsByKeys : baseActionsByKeys; return (event: KeyboardEvent) => { const combo = keyboardEventToKeyComboString(event); // console.log(`pressed: ${combo}`); - if (actionsByKeys[combo]) { - const handled = actionsByKeys[combo](context.editor, context); + if (keySetToUse[combo]) { + const handled = keySetToUse[combo](context.editor, context); if (handled) { event.stopPropagation(); event.preventDefault(); @@ -127,8 +134,8 @@ function overrideDefaultCommands(editor: LexicalEditor) { }, COMMAND_PRIORITY_HIGH); } -export function registerShortcuts(context: EditorUiContext) { - const listener = createKeyDownListener(context); +export function registerShortcuts(context: EditorUiContext, useExtended: boolean) { + const listener = createKeyDownListener(context, useExtended); overrideDefaultCommands(context.editor); return context.editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => { diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index d6af99638..a3ada5c89 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -227,6 +227,7 @@ export function getBasicEditorToolbar(context: EditorUiContext): EditorContainer new EditorButton(bold), new EditorButton(italic), new EditorButton(link), + new EditorButton(code), new EditorButton(bulletList), new EditorButton(numberList), ]) diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 6082c59de..c0d4fbc63 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -256,8 +256,8 @@ class BookTest extends TestCase { $book = $this->entities->book(); - $input = '

    Test

    Contenta

    Hello

    '; - $expected = '

    Contenta

    '; + $input = '

    Test

    Contenta

    Hello
    code

    '; + $expected = '

    Contentacode

    '; $this->asEditor()->put($book->getUrl(), [ 'name' => $book->name, From a2bb5bdf10bbee40a6eb12e40101ea61f0a92021 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 17 Apr 2026 21:22:04 +0100 Subject: [PATCH 21/69] Meta: Updated COC, templates, PR template for community rules Added reference to new community rules page where sensible. --- .github/CODE_OF_CONDUCT.md | 86 +--------------------- .github/ISSUE_TEMPLATE/feature_request.yml | 10 +++ .github/pull_request_template.md | 11 +++ readme.md | 9 ++- 4 files changed, 28 insertions(+), 88 deletions(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 2c6317af6..7a0265672 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -1,84 +1,2 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of experience, -education, socio-economic status, nationality, personal appearance, race, -religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -### Project Maintainer Standards - -Project maintainers should generally follow these additional standards: - -* Avoid using a negative or harsh tone in communication, Even if the other party -is being negative themselves. -* When providing criticism, try to make it constructive to lead the other person -down the correct path. -* Keep the [project definition](https://github.com/BookStackApp/BookStack#project-definition) -in mind when deciding what's in scope of the Project. - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. In addition, Project -maintainers are responsible for following the standards themselves. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at the email address shown on [the profile here](https://github.com/ssddanbrown). All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html - -[homepage]: https://www.contributor-covenant.org +Please find our community rules on our website here: +https://www.bookstackapp.com/about/community-rules/ \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 0ebb8e72f..ca1f2b830 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -56,3 +56,13 @@ body: description: Add any other context or screenshots about the feature request here. validations: required: false + - type: checkboxes + id: ai-thoughts + attributes: + label: Have you used generative AI/LLMs to create any thoughts in this request? + description: | + We ask that no machine generated thoughts or ideas are provided, to avoid us spending time considering the ideas + of a machine instead of a human. Further guidance on this can be found [in the BookStack community rules](https://www.bookstackapp.com/about/community-rules/#use-of-llmsai). + options: + - label: This request only contains the thoughts & ideas of a human + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..70f105874 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Details + + + + +## Checklist + + + +- [ ] I have read the [BookStack community rules](https://www.bookstackapp.com/about/community-rules/). +- [ ] This PR does not feature significant use of LLM/AI generation as per the community rules above. diff --git a/readme.md b/readme.md index 00eb13504..d3a408ad0 100644 --- a/readme.md +++ b/readme.md @@ -9,8 +9,9 @@
    [![Alternate Source](https://img.shields.io/static/v1?label=Alt+Source&message=Git&color=ef391a&logo=git)](https://source.bookstackapp.com/) [![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/) -[![Discord](https://img.shields.io/static/v1?label=Discord&message=chat&color=738adb&logo=discord)](https://www.bookstackapp.com/links/discord) +[![Community Discussions](https://img.shields.io/static/v1?label=Community&message=Discussions&color=4d36c4&logo=zulip)](https://community.bookstackapp.com/) [![Mastodon](https://img.shields.io/static/v1?label=Mastodon&message=@bookstack&color=595aff&logo=mastodon)](https://www.bookstackapp.com/links/mastodon) +[![Discord](https://img.shields.io/static/v1?label=Discord&message=chat&color=738adb&logo=discord)](https://www.bookstackapp.com/links/discord)
    [![PeerTube](https://img.shields.io/static/v1?label=PeerTube&message=bookstack@foss.video&color=f2690d&logo=peertube)](https://foss.video/c/bookstack) [![YouTube](https://img.shields.io/static/v1?label=YouTube&message=bookstackapp&color=ff0000&logo=youtube)](https://www.youtube.com/bookstackapp) @@ -20,11 +21,10 @@ A platform for storing and organising information and documentation. Details for * [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation) * [Documentation](https://www.bookstackapp.com/docs) * [Demo Instance](https://demo.bookstackapp.com) - * [Admin Login](https://demo.bookstackapp.com/login?email=admin@example.com&password=password) * [Screenshots](https://www.bookstackapp.com/#screenshots) * [BookStack Blog](https://www.bookstackapp.com/blog) * [Issue List](https://github.com/BookStackApp/BookStack/issues) -* [Discord Chat](https://www.bookstackapp.com/links/discord) +* [Community Discussions](https://community.bookstackapp.com/) * [Support Options](https://www.bookstackapp.com/support/) ## 📚 Project Definition @@ -124,8 +124,9 @@ Feel free to [create issues](https://github.com/BookStackApp/BookStack/issues/ne Pull requests are welcome but, unless it's a small tweak, it may be best to open the pull request early or create an issue for your intended change to discuss how it will fit into the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project. Pull requests should be created from the `development` branch since they will be merged back into `development` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly. +See the [Development & Testing](#-development--testing) section above for further development guidance. -The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/development/.github/CODE_OF_CONDUCT.md). +The project's community rules, including those for raising issues and making code contributions, [can be found here](https://www.bookstackapp.com/about/community-rules/). ## 🔒 Security From 083fb1a600f1eeb8acb719675bdeb089c86ceee1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 18 Apr 2026 20:43:27 +0100 Subject: [PATCH 22/69] Maintenance: Updated $request->get instance to use input --- .../Controllers/ForgotPasswordController.php | 4 ++-- app/Access/Controllers/LoginController.php | 8 ++++---- .../Controllers/MfaBackupCodesController.php | 2 +- app/Access/Controllers/MfaController.php | 4 ++-- .../Controllers/ResetPasswordController.php | 4 ++-- app/Access/Controllers/Saml2Controller.php | 4 ++-- app/Access/Controllers/SocialController.php | 2 +- .../Controllers/UserInviteController.php | 2 +- .../Controllers/AuditLogController.php | 14 ++++++------- .../Controllers/FavouriteController.php | 2 +- app/Activity/Controllers/TagController.php | 6 +++--- app/Api/UserApiTokenController.php | 8 ++++---- app/Entities/Controllers/BookController.php | 4 ++-- .../Controllers/BookshelfApiController.php | 4 ++-- .../Controllers/BookshelfController.php | 4 ++-- .../Controllers/ChapterApiController.php | 2 +- .../Controllers/ChapterController.php | 6 +++--- .../Controllers/PageApiController.php | 8 ++++---- app/Entities/Controllers/PageController.php | 8 ++++---- .../Controllers/PageTemplateController.php | 4 ++-- app/Entities/Tools/PermissionsUpdater.php | 4 ++-- app/Search/SearchApiController.php | 6 +++--- app/Search/SearchController.php | 20 +++++++++---------- app/Search/SearchOptions.php | 2 +- app/Settings/AppSettingsStore.php | 4 ++-- app/Settings/MaintenanceController.php | 2 +- app/Sorting/BookSortController.php | 4 ++-- .../Controllers/AttachmentApiController.php | 4 ++-- .../Controllers/AttachmentController.php | 16 +++++++-------- .../Controllers/DrawioImageController.php | 12 +++++------ .../Controllers/GalleryImageController.php | 10 +++++----- app/Users/Controllers/RoleController.php | 4 ++-- .../Controllers/UserAccountController.php | 6 +++--- app/Users/Controllers/UserApiController.php | 2 +- app/Users/Controllers/UserController.php | 4 ++-- .../Controllers/UserPreferencesController.php | 8 ++++---- .../Controllers/UserSearchController.php | 4 ++-- app/Util/SimpleListOptions.php | 2 +- 38 files changed, 107 insertions(+), 107 deletions(-) diff --git a/app/Access/Controllers/ForgotPasswordController.php b/app/Access/Controllers/ForgotPasswordController.php index 36dd97755..e8127e617 100644 --- a/app/Access/Controllers/ForgotPasswordController.php +++ b/app/Access/Controllers/ForgotPasswordController.php @@ -45,11 +45,11 @@ class ForgotPasswordController extends Controller ); if ($response === Password::RESET_LINK_SENT) { - $this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email')); + $this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->input('email')); } if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) { - $message = trans('auth.reset_password_sent', ['email' => $request->get('email')]); + $message = trans('auth.reset_password_sent', ['email' => $request->input('email')]); $this->showSuccessNotification($message); return redirect('/password/email')->with('status', trans($response)); diff --git a/app/Access/Controllers/LoginController.php b/app/Access/Controllers/LoginController.php index ce872ba88..4694f22e4 100644 --- a/app/Access/Controllers/LoginController.php +++ b/app/Access/Controllers/LoginController.php @@ -32,12 +32,12 @@ class LoginController extends Controller { $socialDrivers = $this->socialDriverManager->getActive(); $authMethod = config('auth.method'); - $preventInitiation = $request->get('prevent_auto_init') === 'true'; + $preventInitiation = $request->input('prevent_auto_init') === 'true'; if ($request->has('email')) { session()->flashInput([ - 'email' => $request->get('email'), - 'password' => (config('app.env') === 'demo') ? $request->get('password', '') : '', + 'email' => $request->input('email'), + 'password' => (config('app.env') === 'demo') ? $request->input('password', '') : '', ]); } @@ -62,7 +62,7 @@ class LoginController extends Controller public function login(Request $request) { $this->validateLogin($request); - $username = $request->get($this->username()); + $username = $request->input($this->username()); // Check login throttling attempts to see if they've gone over the limit if ($this->hasTooManyLoginAttempts($request)) { diff --git a/app/Access/Controllers/MfaBackupCodesController.php b/app/Access/Controllers/MfaBackupCodesController.php index 5c334674e..0a6416a17 100644 --- a/app/Access/Controllers/MfaBackupCodesController.php +++ b/app/Access/Controllers/MfaBackupCodesController.php @@ -84,7 +84,7 @@ class MfaBackupCodesController extends Controller ], ]); - $updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes); + $updatedCodes = $codeService->removeInputCodeFromSet($request->input('code'), $codes); MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes); $mfaSession->markVerifiedForUser($user); diff --git a/app/Access/Controllers/MfaController.php b/app/Access/Controllers/MfaController.php index c9100ef91..181cfc0b8 100644 --- a/app/Access/Controllers/MfaController.php +++ b/app/Access/Controllers/MfaController.php @@ -51,14 +51,14 @@ class MfaController extends Controller */ public function verify(Request $request) { - $desiredMethod = $request->get('method'); + $desiredMethod = $request->input('method'); $userMethods = $this->currentOrLastAttemptedUser() ->mfaValues() ->get(['id', 'method']) ->groupBy('method'); // Basic search for the default option for a user. - // (Prioritises totp over backup codes) + // (Prioritises TOTP over backup codes) $method = $userMethods->has($desiredMethod) ? $desiredMethod : $userMethods->keys()->sort()->reverse()->first(); $otherMethods = $userMethods->keys()->filter(function ($userMethod) use ($method) { return $method !== $userMethod; diff --git a/app/Access/Controllers/ResetPasswordController.php b/app/Access/Controllers/ResetPasswordController.php index 3af65d17f..e81c98b28 100644 --- a/app/Access/Controllers/ResetPasswordController.php +++ b/app/Access/Controllers/ResetPasswordController.php @@ -48,7 +48,7 @@ class ResetPasswordController extends Controller // Here we will attempt to reset the user's password. If it is successful we // will update the password on an actual user model and persist it to the - // database. Otherwise we will parse the error and return the response. + // database. Otherwise, we will parse the error and return the response. $credentials = $request->only('email', 'password', 'password_confirmation', 'token'); $response = Password::broker()->reset($credentials, function (User $user, string $password) { $user->password = Hash::make($password); @@ -63,7 +63,7 @@ class ResetPasswordController extends Controller // redirect them back to where they came from with their error message. return $response === Password::PASSWORD_RESET ? $this->sendResetResponse() - : $this->sendResetFailedResponse($request, $response, $request->get('token')); + : $this->sendResetFailedResponse($request, $response, $request->input('token')); } /** diff --git a/app/Access/Controllers/Saml2Controller.php b/app/Access/Controllers/Saml2Controller.php index 6f802370e..39598b143 100644 --- a/app/Access/Controllers/Saml2Controller.php +++ b/app/Access/Controllers/Saml2Controller.php @@ -78,7 +78,7 @@ class Saml2Controller extends Controller */ public function startAcs(Request $request) { - $samlResponse = $request->get('SAMLResponse', null); + $samlResponse = $request->input('SAMLResponse', null); if (empty($samlResponse)) { $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); @@ -100,7 +100,7 @@ class Saml2Controller extends Controller */ public function processAcs(Request $request) { - $acsId = $request->get('id', null); + $acsId = $request->input('id', null); $cacheKey = 'saml2_acs:' . $acsId; $samlResponse = null; diff --git a/app/Access/Controllers/SocialController.php b/app/Access/Controllers/SocialController.php index 07f57062d..5a090c7ca 100644 --- a/app/Access/Controllers/SocialController.php +++ b/app/Access/Controllers/SocialController.php @@ -67,7 +67,7 @@ class SocialController extends Controller if ($request->has('error') && $request->has('error_description')) { throw new SocialSignInException(trans('errors.social_login_bad_response', [ 'socialAccount' => $socialDriver, - 'error' => $request->get('error_description'), + 'error' => $request->input('error_description'), ]), '/login'); } diff --git a/app/Access/Controllers/UserInviteController.php b/app/Access/Controllers/UserInviteController.php index 9ee05b84f..091b68e55 100644 --- a/app/Access/Controllers/UserInviteController.php +++ b/app/Access/Controllers/UserInviteController.php @@ -67,7 +67,7 @@ class UserInviteController extends Controller } $user = $this->userRepo->getById($userId); - $user->password = Hash::make($request->get('password')); + $user->password = Hash::make($request->input('password')); $user->email_confirmed = true; $user->save(); diff --git a/app/Activity/Controllers/AuditLogController.php b/app/Activity/Controllers/AuditLogController.php index c4f9b91ed..ed1421c0d 100644 --- a/app/Activity/Controllers/AuditLogController.php +++ b/app/Activity/Controllers/AuditLogController.php @@ -17,19 +17,19 @@ class AuditLogController extends Controller $this->checkPermission(Permission::SettingsManage); $this->checkPermission(Permission::UsersManage); - $sort = $request->get('sort', 'activity_date'); - $order = $request->get('order', 'desc'); + $sort = $request->input('sort', 'activity_date'); + $order = $request->input('order', 'desc'); $listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([ 'created_at' => trans('settings.audit_table_date'), 'type' => trans('settings.audit_table_event'), ]); $filters = [ - 'event' => $request->get('event', ''), - 'date_from' => $request->get('date_from', ''), - 'date_to' => $request->get('date_to', ''), - 'user' => $request->get('user', ''), - 'ip' => $request->get('ip', ''), + 'event' => $request->input('event', ''), + 'date_from' => $request->input('date_from', ''), + 'date_to' => $request->input('date_to', ''), + 'user' => $request->input('user', ''), + 'ip' => $request->input('ip', ''), ]; $query = Activity::query() diff --git a/app/Activity/Controllers/FavouriteController.php b/app/Activity/Controllers/FavouriteController.php index deeb4b0af..65bae276d 100644 --- a/app/Activity/Controllers/FavouriteController.php +++ b/app/Activity/Controllers/FavouriteController.php @@ -20,7 +20,7 @@ class FavouriteController extends Controller public function index(Request $request, QueryTopFavourites $topFavourites) { $viewCount = 20; - $page = intval($request->get('page', 1)); + $page = intval($request->input('page', 1)); $favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount)); $hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null; diff --git a/app/Activity/Controllers/TagController.php b/app/Activity/Controllers/TagController.php index 723dc4ab4..b57c79825 100644 --- a/app/Activity/Controllers/TagController.php +++ b/app/Activity/Controllers/TagController.php @@ -46,7 +46,7 @@ class TagController extends Controller */ public function getNameSuggestions(Request $request) { - $searchTerm = $request->get('search', ''); + $searchTerm = $request->input('search', ''); $suggestions = $this->tagRepo->getNameSuggestions($searchTerm); return response()->json($suggestions); @@ -57,8 +57,8 @@ class TagController extends Controller */ public function getValueSuggestions(Request $request) { - $searchTerm = $request->get('search', ''); - $tagName = $request->get('name', ''); + $searchTerm = $request->input('search', ''); + $tagName = $request->input('name', ''); $suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName); return response()->json($suggestions); diff --git a/app/Api/UserApiTokenController.php b/app/Api/UserApiTokenController.php index 2ca9e2235..2894ede3a 100644 --- a/app/Api/UserApiTokenController.php +++ b/app/Api/UserApiTokenController.php @@ -48,11 +48,11 @@ class UserApiTokenController extends Controller $secret = Str::random(32); $token = (new ApiToken())->forceFill([ - 'name' => $request->get('name'), + 'name' => $request->input('name'), 'token_id' => Str::random(32), 'secret' => Hash::make($secret), 'user_id' => $user->id, - 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(), + 'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(), ]); while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) { @@ -100,8 +100,8 @@ class UserApiTokenController extends Controller [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); $token->fill([ - 'name' => $request->get('name'), - 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(), + 'name' => $request->input('name'), + 'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(), ])->save(); $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token); diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index fca530f8a..98470d91c 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -144,7 +144,7 @@ class BookController extends Controller View::incrementFor($book); if ($request->has('shelf')) { - $this->shelfContext->setShelfContext(intval($request->get('shelf'))); + $this->shelfContext->setShelfContext(intval($request->input('shelf'))); } $this->setPageTitle($book->getShortName()); @@ -263,7 +263,7 @@ class BookController extends Controller $this->checkOwnablePermission(Permission::BookView, $book); $this->checkPermission(Permission::BookCreateAll); - $newName = $request->get('name') ?: $book->name; + $newName = $request->input('name') ?: $book->name; $bookCopy = $cloner->cloneBook($book, $newName); $this->showSuccessNotification(trans('entities.books_copy_success')); diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php index 735742060..e620eb59c 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -49,7 +49,7 @@ class BookshelfApiController extends ApiController $this->checkPermission(Permission::BookshelfCreateAll); $requestData = $this->validate($request, $this->rules()['create']); - $bookIds = $request->get('books', []); + $bookIds = $request->input('books', []); $shelf = $this->bookshelfRepo->create($requestData, $bookIds); return response()->json($this->forJsonDisplay($shelf)); @@ -88,7 +88,7 @@ class BookshelfApiController extends ApiController $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); $requestData = $this->validate($request, $this->rules()['update']); - $bookIds = $request->get('books', null); + $bookIds = $request->input('books', null); $shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds); diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index f5f4a90bf..1e8b26b51 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -94,7 +94,7 @@ class BookshelfController extends Controller 'tags' => ['array'], ]); - $bookIds = explode(',', $request->get('books', '')); + $bookIds = explode(',', $request->input('books', '')); $shelf = $this->shelfRepo->create($validated, $bookIds); return redirect($shelf->getUrl()); @@ -196,7 +196,7 @@ class BookshelfController extends Controller unset($validated['image']); } - $bookIds = explode(',', $request->get('books', '')); + $bookIds = explode(',', $request->input('books', '')); $shelf = $this->shelfRepo->update($shelf, $validated, $bookIds); return redirect($shelf->getUrl()); diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php index 6aa62f887..9e0c69b17 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -64,7 +64,7 @@ class ChapterApiController extends ApiController { $requestData = $this->validate($request, $this->rules['create']); - $bookId = $request->get('book_id'); + $bookId = $request->input('book_id'); $book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId)); $this->checkOwnablePermission(Permission::ChapterCreate, $book); diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index 878ee42b5..db2391599 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -203,7 +203,7 @@ class ChapterController extends Controller $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); - $entitySelection = $request->get('entity_selection', null); + $entitySelection = $request->input('entity_selection', null); if ($entitySelection === null || $entitySelection === '') { return redirect($chapter->getUrl()); } @@ -248,7 +248,7 @@ class ChapterController extends Controller { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $entitySelection = $request->get('entity_selection') ?: null; + $entitySelection = $request->input('entity_selection') ?: null; $newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent(); if (!$newParentBook instanceof Book) { @@ -259,7 +259,7 @@ class ChapterController extends Controller $this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook); - $newName = $request->get('name') ?: $chapter->name; + $newName = $request->input('name') ?: $chapter->name; $chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName); $this->showSuccessNotification(trans('entities.chapters_copy_success')); diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 197018cca..38042e670 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -74,9 +74,9 @@ class PageApiController extends ApiController $this->validate($request, $this->rules['create']); if ($request->has('chapter_id')) { - $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id'))); + $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id'))); } else { - $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id'))); + $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id'))); } $this->checkOwnablePermission(Permission::PageCreate, $parent); @@ -133,9 +133,9 @@ class PageApiController extends ApiController $parent = null; if ($request->has('chapter_id')) { - $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id'))); + $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id'))); } elseif ($request->has('book_id')) { - $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id'))); + $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id'))); } if ($parent && !$parent->matches($page->getParent())) { diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index 8778560e2..82edfbc27 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -88,7 +88,7 @@ class PageController extends Controller $page = $this->pageRepo->getNewDraftPage($parent); $this->pageRepo->publishDraft($page, [ - 'name' => $request->get('name'), + 'name' => $request->input('name'), ]); return redirect($page->getUrl('/edit')); @@ -408,7 +408,7 @@ class PageController extends Controller $this->checkOwnablePermission(Permission::PageUpdate, $page); $this->checkOwnablePermission(Permission::PageDelete, $page); - $entitySelection = $request->get('entity_selection', null); + $entitySelection = $request->input('entity_selection', null); if ($entitySelection === null || $entitySelection === '') { return redirect($page->getUrl()); } @@ -453,7 +453,7 @@ class PageController extends Controller $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageView, $page); - $entitySelection = $request->get('entity_selection') ?: null; + $entitySelection = $request->input('entity_selection') ?: null; $newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent(); if (!$newParent instanceof Book && !$newParent instanceof Chapter) { @@ -464,7 +464,7 @@ class PageController extends Controller $this->checkOwnablePermission(Permission::PageCreate, $newParent); - $newName = $request->get('name') ?: $page->name; + $newName = $request->input('name') ?: $page->name; $pageCopy = $cloner->clonePage($page, $newParent, $newName); $this->showSuccessNotification(trans('entities.pages_copy_success')); diff --git a/app/Entities/Controllers/PageTemplateController.php b/app/Entities/Controllers/PageTemplateController.php index c0b972148..9ff2fe029 100644 --- a/app/Entities/Controllers/PageTemplateController.php +++ b/app/Entities/Controllers/PageTemplateController.php @@ -21,8 +21,8 @@ class PageTemplateController extends Controller */ public function list(Request $request) { - $page = $request->get('page', 1); - $search = $request->get('search', ''); + $page = $request->input('page', 1); + $search = $request->input('search', ''); $count = 10; $query = $this->pageQueries->visibleTemplates() diff --git a/app/Entities/Tools/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php index f3165b603..5770d02f1 100644 --- a/app/Entities/Tools/PermissionsUpdater.php +++ b/app/Entities/Tools/PermissionsUpdater.php @@ -20,8 +20,8 @@ class PermissionsUpdater */ public function updateFromPermissionsForm(Entity $entity, Request $request): void { - $permissions = $request->get('permissions', null); - $ownerId = $request->get('owned_by', null); + $permissions = $request->input('permissions', null); + $ownerId = $request->input('owned_by', null); $entity->permissions()->delete(); diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index 5de7a5110..3ecb955ae 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -40,9 +40,9 @@ class SearchApiController extends ApiController { $this->validate($request, $this->rules['all']); - $options = SearchOptions::fromString($request->get('query') ?? ''); - $page = intval($request->get('page', '0')) ?: 1; - $count = min(intval($request->get('count', '0')) ?: 20, 100); + $options = SearchOptions::fromString($request->input('query') ?? ''); + $page = intval($request->input('page', '0')) ?: 1; + $count = min(intval($request->input('count', '0')) ?: 20, 100); $results = $this->searchRunner->searchEntities($options, 'all', $page, $count); $this->resultsFormatter->format($results['results']->all(), $options); diff --git a/app/Search/SearchController.php b/app/Search/SearchController.php index 348d44a42..50a73910a 100644 --- a/app/Search/SearchController.php +++ b/app/Search/SearchController.php @@ -24,7 +24,7 @@ class SearchController extends Controller { $searchOpts = SearchOptions::fromRequest($request); $fullSearchString = $searchOpts->toString(); - $page = intval($request->get('page', '0')) ?: 1; + $page = intval($request->input('page', '0')) ?: 1; $count = setting()->getInteger('lists-page-count-search', 18, 1, 1000); $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, $count); @@ -49,7 +49,7 @@ class SearchController extends Controller */ public function searchBook(Request $request, int $bookId) { - $term = $request->get('term', ''); + $term = $request->input('term', ''); $results = $this->searchRunner->searchBook($bookId, $term); return view('entities.list', ['entities' => $results]); @@ -60,7 +60,7 @@ class SearchController extends Controller */ public function searchChapter(Request $request, int $chapterId) { - $term = $request->get('term', ''); + $term = $request->input('term', ''); $results = $this->searchRunner->searchChapter($chapterId, $term); return view('entities.list', ['entities' => $results]); @@ -72,9 +72,9 @@ class SearchController extends Controller */ public function searchForSelector(Request $request, QueryPopular $queryPopular) { - $entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book']; - $searchTerm = $request->get('term', false); - $permission = $request->get('permission', 'view'); + $entityTypes = $request->filled('types') ? explode(',', $request->input('types')) : ['page', 'chapter', 'book']; + $searchTerm = $request->input('term', false); + $permission = $request->input('permission', 'view'); // Search for entities otherwise show most popular if ($searchTerm !== false) { @@ -93,7 +93,7 @@ class SearchController extends Controller */ public function templatesForSelector(Request $request) { - $searchTerm = $request->get('term', false); + $searchTerm = $request->input('term', false); if ($searchTerm !== false) { $searchOptions = SearchOptions::fromString($searchTerm); @@ -119,7 +119,7 @@ class SearchController extends Controller */ public function searchSuggestions(Request $request) { - $searchTerm = $request->get('term', ''); + $searchTerm = $request->input('term', ''); $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 5)['results']; foreach ($entities as $entity) { @@ -136,8 +136,8 @@ class SearchController extends Controller */ public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher) { - $type = $request->get('entity_type', null); - $id = $request->get('entity_id', null); + $type = $request->input('entity_type', null); + $id = $request->input('entity_id', null); $entities = $siblingFetcher->fetch($type, $id); diff --git a/app/Search/SearchOptions.php b/app/Search/SearchOptions.php index cfd068386..f3eb58b6b 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -51,7 +51,7 @@ class SearchOptions } if ($request->has('term')) { - return static::fromString($request->get('term')); + return static::fromString($request->input('term')); } $instance = new SearchOptions(); diff --git a/app/Settings/AppSettingsStore.php b/app/Settings/AppSettingsStore.php index e098d87f8..9c370ca27 100644 --- a/app/Settings/AppSettingsStore.php +++ b/app/Settings/AppSettingsStore.php @@ -44,7 +44,7 @@ class AppSettingsStore } // Clear icon image if requested - if ($request->get('app_icon_reset')) { + if ($request->input('app_icon_reset')) { $this->destroyExistingSettingImage('app-icon'); setting()->remove('app-icon'); foreach ($sizes as $size) { @@ -67,7 +67,7 @@ class AppSettingsStore } // Clear logo image if requested - if ($request->get('app_logo_reset')) { + if ($request->input('app_logo_reset')) { $this->destroyExistingSettingImage('app-logo'); setting()->remove('app-logo'); } diff --git a/app/Settings/MaintenanceController.php b/app/Settings/MaintenanceController.php index b2b2226bf..64e6c0111 100644 --- a/app/Settings/MaintenanceController.php +++ b/app/Settings/MaintenanceController.php @@ -38,7 +38,7 @@ class MaintenanceController extends Controller $this->checkPermission(Permission::SettingsManage); $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'cleanup-images'); - $checkRevisions = !($request->get('ignore_revisions', 'false') === 'true'); + $checkRevisions = !($request->input('ignore_revisions', 'false') === 'true'); $dryRun = !($request->has('confirm')); $imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun); diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php index 7e2ee5465..4ddbb14bc 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -58,7 +58,7 @@ class BookSortController extends Controller // Sort via map if ($request->filled('sort-tree')) { (new DatabaseTransaction(function () use ($book, $request, $sorter, &$loggedActivityForBook) { - $sortMap = BookSortMap::fromJson($request->get('sort-tree')); + $sortMap = BookSortMap::fromJson($request->input('sort-tree')); $booksInvolved = $sorter->sortUsingMap($sortMap); // Add activity for involved books. @@ -72,7 +72,7 @@ class BookSortController extends Controller } if ($request->filled('auto-sort')) { - $sortSetId = intval($request->get('auto-sort')) ?: null; + $sortSetId = intval($request->input('auto-sort')) ?: null; if ($sortSetId && SortRule::query()->find($sortSetId) === null) { $sortSetId = null; } diff --git a/app/Uploads/Controllers/AttachmentApiController.php b/app/Uploads/Controllers/AttachmentApiController.php index ea3c4a962..2448b79b5 100644 --- a/app/Uploads/Controllers/AttachmentApiController.php +++ b/app/Uploads/Controllers/AttachmentApiController.php @@ -50,7 +50,7 @@ class AttachmentApiController extends ApiController $this->checkPermission(Permission::AttachmentCreateAll); $requestData = $this->validate($request, $this->rules()['create']); - $pageId = $request->get('uploaded_to'); + $pageId = $request->input('uploaded_to'); $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageUpdate, $page); @@ -134,7 +134,7 @@ class AttachmentApiController extends ApiController $page = $attachment->page; if ($requestData['uploaded_to'] ?? false) { - $pageId = $request->get('uploaded_to'); + $pageId = $request->input('uploaded_to'); $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $attachment->uploaded_to = $requestData['uploaded_to']; } diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index 9c60fa415..edcf066ac 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -39,7 +39,7 @@ class AttachmentController extends Controller 'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()), ]); - $pageId = $request->get('uploaded_to'); + $pageId = $request->input('uploaded_to'); $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $this->checkPermission(Permission::AttachmentCreateAll); @@ -125,8 +125,8 @@ class AttachmentController extends Controller $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment); $attachment = $this->attachmentService->updateFile($attachment, [ - 'name' => $request->get('attachment_edit_name'), - 'link' => $request->get('attachment_edit_url'), + 'name' => $request->input('attachment_edit_name'), + 'link' => $request->input('attachment_edit_url'), ]); return view('attachments.manager-edit-form', [ @@ -141,7 +141,7 @@ class AttachmentController extends Controller */ public function attachLink(Request $request) { - $pageId = $request->get('attachment_link_uploaded_to'); + $pageId = $request->input('attachment_link_uploaded_to'); try { $this->validate($request, [ @@ -161,8 +161,8 @@ class AttachmentController extends Controller $this->checkPermission(Permission::AttachmentCreateAll); $this->checkOwnablePermission(Permission::PageUpdate, $page); - $attachmentName = $request->get('attachment_link_name'); - $link = $request->get('attachment_link_url'); + $attachmentName = $request->input('attachment_link_name'); + $link = $request->input('attachment_link_url'); $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId)); return view('attachments.manager-link-form', [ @@ -198,7 +198,7 @@ class AttachmentController extends Controller $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageUpdate, $page); - $attachmentOrder = $request->get('order'); + $attachmentOrder = $request->input('order'); $this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId); return response()->json(['message' => trans('entities.attachments_order_updated')]); @@ -231,7 +231,7 @@ class AttachmentController extends Controller $attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment); $attachmentSize = $this->attachmentService->getAttachmentFileSize($attachment); - if ($request->get('open') === 'true') { + if ($request->input('open') === 'true') { return $this->download()->streamedInline($attachmentStream, $fileName, $attachmentSize); } diff --git a/app/Uploads/Controllers/DrawioImageController.php b/app/Uploads/Controllers/DrawioImageController.php index f44acd997..8295febc1 100644 --- a/app/Uploads/Controllers/DrawioImageController.php +++ b/app/Uploads/Controllers/DrawioImageController.php @@ -24,10 +24,10 @@ class DrawioImageController extends Controller */ public function list(Request $request, ImageResizer $resizer) { - $page = $request->get('page', 1); - $searchTerm = $request->get('search', null); - $uploadedToFilter = $request->get('uploaded_to', null); - $parentTypeFilter = $request->get('filter_type', null); + $page = $request->input('page', 1); + $searchTerm = $request->input('search', null); + $uploadedToFilter = $request->input('uploaded_to', null); + $parentTypeFilter = $request->input('filter_type', null); $imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm); $viewData = [ @@ -59,10 +59,10 @@ class DrawioImageController extends Controller ]); $this->checkPermission(Permission::ImageCreateAll); - $imageBase64Data = $request->get('image'); + $imageBase64Data = $request->input('image'); try { - $uploadedTo = $request->get('uploaded_to', 0); + $uploadedTo = $request->input('uploaded_to', 0); $image = $this->imageRepo->saveDrawing($imageBase64Data, $uploadedTo); } catch (ImageUploadException $e) { return response($e->getMessage(), 500); diff --git a/app/Uploads/Controllers/GalleryImageController.php b/app/Uploads/Controllers/GalleryImageController.php index 745efcde8..908322be0 100644 --- a/app/Uploads/Controllers/GalleryImageController.php +++ b/app/Uploads/Controllers/GalleryImageController.php @@ -24,10 +24,10 @@ class GalleryImageController extends Controller */ public function list(Request $request, ImageResizer $resizer) { - $page = $request->get('page', 1); - $searchTerm = $request->get('search', null); - $uploadedToFilter = $request->get('uploaded_to', null); - $parentTypeFilter = $request->get('filter_type', null); + $page = $request->input('page', 1); + $searchTerm = $request->input('search', null); + $uploadedToFilter = $request->input('uploaded_to', null); + $parentTypeFilter = $request->input('filter_type', null); $imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm); $viewData = [ @@ -69,7 +69,7 @@ class GalleryImageController extends Controller try { $imageUpload = $request->file('file'); - $uploadedTo = $request->get('uploaded_to', 0); + $uploadedTo = $request->input('uploaded_to', 0); $image = $this->imageRepo->saveNew($imageUpload, 'gallery', $uploadedTo); } catch (ImageUploadException $e) { return response($e->getMessage(), 500); diff --git a/app/Users/Controllers/RoleController.php b/app/Users/Controllers/RoleController.php index 549f6e0ac..b9f06dace 100644 --- a/app/Users/Controllers/RoleController.php +++ b/app/Users/Controllers/RoleController.php @@ -55,7 +55,7 @@ class RoleController extends Controller /** @var ?Role $role */ $role = null; if ($request->has('copy_from')) { - $role = Role::query()->find($request->get('copy_from')); + $role = Role::query()->find($request->input('copy_from')); } if ($role) { @@ -150,7 +150,7 @@ class RoleController extends Controller $this->checkPermission(Permission::UserRolesManage); try { - $migrateRoleId = intval($request->get('migrate_role_id') ?: "0"); + $migrateRoleId = intval($request->input('migrate_role_id') ?: "0"); $this->permissionsRepo->deleteRole($id, $migrateRoleId); } catch (PermissionsException $e) { $this->showErrorNotification($e->getMessage()); diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index a8baba529..21816d5b8 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -106,8 +106,8 @@ class UserAccountController extends Controller */ public function updateShortcuts(Request $request) { - $enabled = $request->get('enabled') === 'true'; - $providedShortcuts = $request->get('shortcut', []); + $enabled = $request->input('enabled') === 'true'; + $providedShortcuts = $request->input('shortcut', []); $shortcuts = new UserShortcutMap($providedShortcuts); setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson()); @@ -218,7 +218,7 @@ class UserAccountController extends Controller { $this->preventAccessInDemoMode(); - $requestNewOwnerId = intval($request->get('new_owner_id')) ?: null; + $requestNewOwnerId = intval($request->input('new_owner_id')) ?: null; $newOwnerId = userCan(Permission::UsersManage) ? $requestNewOwnerId : null; $this->userRepo->destroy(user(), $newOwnerId); diff --git a/app/Users/Controllers/UserApiController.php b/app/Users/Controllers/UserApiController.php index 25753280f..ebc17e262 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -141,7 +141,7 @@ class UserApiController extends ApiController public function delete(Request $request, string $id) { $user = $this->userRepo->getById($id); - $newOwnerId = $request->get('migrate_ownership_id', null); + $newOwnerId = $request->input('migrate_ownership_id', null); $this->userRepo->destroy($user, $newOwnerId); diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index 494221b14..f93c00a89 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -77,7 +77,7 @@ class UserController extends Controller $this->checkPermission(Permission::UsersManage); $authMethod = config('auth.method'); - $sendInvite = ($request->get('send_invite', 'false') === 'true'); + $sendInvite = ($request->input('send_invite', 'false') === 'true'); $externalAuth = $authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'oidc'; $passwordRequired = ($authMethod === 'standard' && !$sendInvite); @@ -202,7 +202,7 @@ class UserController extends Controller $this->checkPermission(Permission::UsersManage); $user = $this->userRepo->getById($id); - $newOwnerId = intval($request->get('new_owner_id')) ?: null; + $newOwnerId = intval($request->input('new_owner_id')) ?: null; $this->userRepo->destroy($user, $newOwnerId); diff --git a/app/Users/Controllers/UserPreferencesController.php b/app/Users/Controllers/UserPreferencesController.php index 0bed2d22a..f4a56b7bf 100644 --- a/app/Users/Controllers/UserPreferencesController.php +++ b/app/Users/Controllers/UserPreferencesController.php @@ -23,7 +23,7 @@ class UserPreferencesController extends Controller return $this->redirectToRequest($request); } - $view = $request->get('view'); + $view = $request->input('view'); if (!in_array($view, ['grid', 'list'])) { $view = 'list'; } @@ -44,8 +44,8 @@ class UserPreferencesController extends Controller return $this->redirectToRequest($request); } - $sort = substr($request->get('sort') ?: 'name', 0, 50); - $order = $request->get('order') === 'desc' ? 'desc' : 'asc'; + $sort = substr($request->input('sort') ?: 'name', 0, 50); + $order = $request->input('order') === 'desc' ? 'desc' : 'asc'; $sortKey = $type . '_sort'; $orderKey = $type . '_sort_order'; @@ -76,7 +76,7 @@ class UserPreferencesController extends Controller return response('Invalid key', 500); } - $newState = $request->get('expand', 'false'); + $newState = $request->input('expand', 'false'); setting()->putForCurrentUser('section_expansion#' . $type, $newState); return response('', 204); diff --git a/app/Users/Controllers/UserSearchController.php b/app/Users/Controllers/UserSearchController.php index bc0543cab..9734255e7 100644 --- a/app/Users/Controllers/UserSearchController.php +++ b/app/Users/Controllers/UserSearchController.php @@ -26,7 +26,7 @@ class UserSearchController extends Controller $this->showPermissionError(); } - $search = $request->get('search', ''); + $search = $request->input('search', ''); $query = User::query() ->orderBy('name', 'asc') ->take(20); @@ -58,7 +58,7 @@ class UserSearchController extends Controller $this->showPermissionError(); } - $search = $request->get('search', ''); + $search = $request->input('search', ''); $query = User::query() ->orderBy('name', 'asc') ->take(20); diff --git a/app/Util/SimpleListOptions.php b/app/Util/SimpleListOptions.php index 81d8a5876..9fb1b98ae 100644 --- a/app/Util/SimpleListOptions.php +++ b/app/Util/SimpleListOptions.php @@ -30,7 +30,7 @@ class SimpleListOptions */ public static function fromRequest(Request $request, string $typeKey, bool $sortDescDefault = false): self { - $search = $request->get('search', ''); + $search = $request->input('search', ''); $sort = setting()->getForCurrentUser($typeKey . '_sort', ''); $order = setting()->getForCurrentUser($typeKey . '_sort_order', $sortDescDefault ? 'desc' : 'asc'); From befa3a8fbb23cb8026fc646b9c8e8617e095b91e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Apr 2026 12:41:11 +0100 Subject: [PATCH 23/69] Permissions: Started addition of revision-view permission --- .../Controllers/PageRevisionController.php | 7 ++++++ app/Permissions/Permission.php | 2 ++ lang/en/settings.php | 1 + resources/views/entities/meta.blade.php | 2 +- .../show-sidebar-section-actions.blade.php | 10 +++++---- .../views/settings/roles/parts/form.blade.php | 1 + .../parts/revisions-permissions-row.blade.php | 22 +++++++++++++++++++ 7 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 resources/views/settings/roles/parts/revisions-permissions-row.blade.php diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index 4bc15e6e9..0d690cb2c 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -34,6 +34,7 @@ class PageRevisionController extends Controller */ public function index(Request $request, string $bookSlug, string $pageSlug) { + $this->checkPermission(Permission::RevisionViewAll); $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([ 'id' => trans('entities.pages_revisions_sort_number') @@ -65,6 +66,8 @@ class PageRevisionController extends Controller */ public function show(string $bookSlug, string $pageSlug, int $revisionId) { + $this->checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); /** @var ?PageRevision $revision */ $revision = $page->revisions()->where('id', '=', $revisionId)->first(); @@ -94,6 +97,8 @@ class PageRevisionController extends Controller */ public function changes(string $bookSlug, string $pageSlug, int $revisionId) { + $this->checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); /** @var ?PageRevision $revision */ $revision = $page->revisions()->where('id', '=', $revisionId)->first(); @@ -130,6 +135,7 @@ class PageRevisionController extends Controller public function restore(string $bookSlug, string $pageSlug, int $revisionId) { $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkPermission(Permission::RevisionViewAll); $this->checkOwnablePermission(Permission::PageUpdate, $page); $page = $this->pageRepo->restoreRevision($page, $revisionId); @@ -145,6 +151,7 @@ class PageRevisionController extends Controller public function destroy(string $bookSlug, string $pageSlug, int $revId) { $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkPermission(Permission::RevisionViewAll); $this->checkOwnablePermission(Permission::PageDelete, $page); $revision = $page->revisions()->where('id', '=', $revId)->first(); diff --git a/app/Permissions/Permission.php b/app/Permissions/Permission.php index 04878ada0..0fbe9693d 100644 --- a/app/Permissions/Permission.php +++ b/app/Permissions/Permission.php @@ -118,6 +118,8 @@ enum Permission: string case PageViewAll = 'page-view-all'; case PageViewOwn = 'page-view-own'; + case RevisionViewAll = 'revision-view-all'; + /** * Get the generic permissions which may be queried for entities. */ diff --git a/lang/en/settings.php b/lang/en/settings.php index c4d1eb136..3937c650f 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Save Role', 'role_users' => 'Users in this role', 'role_users_none' => 'No users are currently assigned to this role', diff --git a/resources/views/entities/meta.blade.php b/resources/views/entities/meta.blade.php index 060c197a4..6c425a240 100644 --- a/resources/views/entities/meta.blade.php +++ b/resources/views/entities/meta.blade.php @@ -9,7 +9,7 @@
    @endif - @if ($entity->isA('page')) + @if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll)) @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} diff --git a/resources/views/pages/parts/show-sidebar-section-actions.blade.php b/resources/views/pages/parts/show-sidebar-section-actions.blade.php index ae115b69e..94061ecb3 100644 --- a/resources/views/pages/parts/show-sidebar-section-actions.blade.php +++ b/resources/views/pages/parts/show-sidebar-section-actions.blade.php @@ -24,10 +24,12 @@ @endif @endif - - @icon('history') - {{ trans('entities.revisions') }} - + @if(userCan(\BookStack\Permissions\Permission::RevisionViewAll)) + + @icon('history') + {{ trans('entities.revisions') }} + + @endif @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $page)) @icon('lock') diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 5a9eca7d2..890f79057 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -79,6 +79,7 @@ @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.books'), 'permissionPrefix' => 'book']) @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.chapters'), 'permissionPrefix' => 'chapter']) @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.pages'), 'permissionPrefix' => 'page']) + @include('settings.roles.parts.revisions-permissions-row', ['title' => trans('entities.revisions'), 'permissionPrefix' => 'revision']) @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.images'), 'permissionPrefix' => 'image']) @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.attachments'), 'permissionPrefix' => 'attachment']) @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.comments'), 'permissionPrefix' => 'comment']) diff --git a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php new file mode 100644 index 000000000..fe886a5d0 --- /dev/null +++ b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php @@ -0,0 +1,22 @@ +
    + +
    + {{ trans('common.create') }}
    + - +
    +
    + {{ trans('common.view') }}
    + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')]) +
    +
    + {{ trans('common.edit') }}
    + - +
    +
    + {{ trans('common.delete') }}
    + {{ trans('settings.role_controlled_by_page_delete') }} +
    +
    \ No newline at end of file From 1339f668ebfd0155c15c122f4257e435d23f9a11 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Apr 2026 15:32:10 +0100 Subject: [PATCH 24/69] Permissions: Added revision-view-all addition migration --- ...41616_add_revision_view_all_permission.php | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 database/migrations/2026_04_19_141616_add_revision_view_all_permission.php diff --git a/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php b/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php new file mode 100644 index 000000000..5a0b9a09b --- /dev/null +++ b/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php @@ -0,0 +1,67 @@ +insertGetId([ + 'name' => 'revision-view-all', + 'created_at' => Carbon::now()->toDateTimeString(), + 'updated_at' => Carbon::now()->toDateTimeString(), + ]); + + // Get ids of page view permissions + $pageViewPermissions = DB::table('role_permissions') + ->whereIn('name', [ + 'page-view-own', + 'page-view-all', + ])->get(); + + if (!$pageViewPermissions->count() === 0) { + return; + } + + // Get role ids which have page view permission + $applicableRoleIds = DB::table('permission_role') + ->whereIn('permission_id', $pageViewPermissions->pluck('id')) + ->pluck('role_id') + ->unique() + ->all(); + + // Assign the new permission to relevant roles + $newPermissionRoles = array_values(array_map(function (int $roleId) use ($permissionId) { + return [ + 'role_id' => $roleId, + 'permission_id' => $permissionId, + ]; + }, $applicableRoleIds)); + + DB::table('permission_role')->insert($newPermissionRoles); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Get the permission to remove + $revisionViewPermission = DB::table('role_permissions') + ->where('name', '=', 'revision-view-all') + ->first(); + + if (!$revisionViewPermission) { + return; + } + + // Remove the permission, and its use on roles, from the database + DB::table('permission_role')->where('permission_id', '=', $revisionViewPermission->id)->delete(); + DB::table('role_permissions')->where('id', '=', $revisionViewPermission->id)->delete(); + } +}; From e7e019d3d44b263031d4a63f91e4c6bdf3b424eb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Apr 2026 15:56:54 +0100 Subject: [PATCH 25/69] Permissions: Added testing coverage for revision-view-all --- tests/Entity/PageRevisionTest.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index 132a10fa4..8b46e84a6 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -4,6 +4,8 @@ namespace Tests\Entity; use BookStack\Activity\ActivityType; use BookStack\Entities\Models\Page; +use BookStack\Entities\Models\PageRevision; +use BookStack\Permissions\Permission; use Tests\TestCase; class PageRevisionTest extends TestCase @@ -257,6 +259,33 @@ class PageRevisionTest extends TestCase $revisionView->assertDontSee('dontwantthishere'); } + public function test_access_to_revision_operation_requires_revision_view_all_permission() + { + $editor = $this->users->editor(); + $this->actingAs($editor); + + $page = $this->entities->page(); + $this->createRevisions($page, 3); + /** @var PageRevision $revision */ + $revision = $page->revisions()->orderBy('id', 'desc')->first(); + + $this->get($page->getUrl())->assertSee($page->getUrl('/revisions'), false); + $this->get($page->getUrl('/revisions'))->assertOk(); + $this->get($revision->getUrl())->assertOk(); + $this->get($revision->getUrl('/changes'))->assertOk(); + $this->put($revision->getUrl('/restore'))->assertRedirect($page->getUrl()); + $this->delete($revision->getUrl('/delete'))->assertRedirect($page->getUrl('/revisions')); + + $this->permissions->removeUserRolePermissions($editor, [Permission::RevisionViewAll]); + + $this->get($page->getUrl())->assertDontSee($page->getUrl('/revisions'), false); + $this->assertPermissionError($this->get($page->getUrl('/revisions'))); + $this->assertPermissionError($this->get($revision->getUrl())); + $this->assertPermissionError($this->get($revision->getUrl('/changes'))); + $this->assertPermissionError($this->put($revision->getUrl('/restore'))); + $this->assertPermissionError($this->delete($revision->getUrl('/delete'))); + } + public function test_revision_restore_action_only_visible_with_permission() { $page = $this->entities->page(); From ec0b0384a20f10a5ec44197a3fd5ca8f9fc543aa Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Apr 2026 16:06:31 +0100 Subject: [PATCH 26/69] Permissions: Tweaks/fixed during review of revision-view-all changes --- app/Entities/Controllers/PageRevisionController.php | 4 ++-- .../2026_04_19_141616_add_revision_view_all_permission.php | 2 +- .../settings/roles/parts/revisions-permissions-row.blade.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index 0d690cb2c..cc6b79bfe 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -134,8 +134,8 @@ class PageRevisionController extends Controller */ public function restore(string $bookSlug, string $pageSlug, int $revisionId) { - $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageUpdate, $page); $page = $this->pageRepo->restoreRevision($page, $revisionId); @@ -150,8 +150,8 @@ class PageRevisionController extends Controller */ public function destroy(string $bookSlug, string $pageSlug, int $revId) { - $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageDelete, $page); $revision = $page->revisions()->where('id', '=', $revId)->first(); diff --git a/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php b/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php index 5a0b9a09b..e4b51ff70 100644 --- a/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php +++ b/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php @@ -24,7 +24,7 @@ return new class extends Migration 'page-view-all', ])->get(); - if (!$pageViewPermissions->count() === 0) { + if ($pageViewPermissions->count() === 0) { return; } diff --git a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php index fe886a5d0..326925ef9 100644 --- a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php @@ -19,4 +19,4 @@ {{ trans('common.delete') }}
    {{ trans('settings.role_controlled_by_page_delete') }} - \ No newline at end of file + From 426f9ac4934308da9f57580bc0e2fe399346cbb1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Apr 2026 16:23:16 +0100 Subject: [PATCH 27/69] Permissions: Prevent export revision metadata view without permission --- resources/views/exports/parts/meta.blade.php | 2 +- tests/Exports/HtmlExportTest.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/resources/views/exports/parts/meta.blade.php b/resources/views/exports/parts/meta.blade.php index 00117f4a1..07eff14a4 100644 --- a/resources/views/exports/parts/meta.blade.php +++ b/resources/views/exports/parts/meta.blade.php @@ -1,5 +1,5 @@
    - @if ($entity->isA('page')) + @if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll)) @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }}
    @endif diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php index f23352e0e..223a8c922 100644 --- a/tests/Exports/HtmlExportTest.php +++ b/tests/Exports/HtmlExportTest.php @@ -5,6 +5,7 @@ namespace Tests\Exports; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; +use BookStack\Permissions\Permission; use Illuminate\Support\Facades\Storage; use Tests\TestCase; @@ -229,6 +230,20 @@ class HtmlExportTest extends TestCase $resp->assertDontSee('ExportWizardTheFifth'); } + public function test_page_export_only_includes_revision_count_if_user_has_revision_view_permissions() + { + $editor = $this->users->editor(); + $page = $this->entities->page(); + + $resp = $this->actingAs($editor)->get($page->getUrl('/export/html')); + $resp->assertSee('Revision #'); + + $this->permissions->removeUserRolePermissions($editor, [Permission::RevisionViewAll]); + + $resp = $this->actingAs($editor)->get($page->getUrl('/export/html')); + $resp->assertDontSee('Revision #'); + } + public function test_html_exports_contain_csp_meta_tag() { $entities = [ From 4f370ccddb4f5fd8eddff5915a9413786ae2a2c7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 20 Apr 2026 14:32:13 +0100 Subject: [PATCH 28/69] Styles: Aligned fonts set on content and headers for exports During review of #6069 --- resources/sass/export-styles.scss | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/resources/sass/export-styles.scss b/resources/sass/export-styles.scss index 22f15d1b8..36901c0f1 100644 --- a/resources/sass/export-styles.scss +++ b/resources/sass/export-styles.scss @@ -12,12 +12,16 @@ html, body { } body { - font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; margin: 0; padding: 0; display: block; } +// Set fonts to common system fonts, starting with DejaVu Sans due to support in DOMPDF +body, h1, h2, h3, h4, h5, h6 { + font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; +} + table { border-spacing: 0; border-collapse: collapse; @@ -64,11 +68,6 @@ body.export-format-pdf { font-size: 14px; line-height: 1.2; - // Ensure heading glyph coverage for PDF engines that don't handle CSS vars well. - h1, h2, h3, h4, h5, h6 { - font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; - } - h1, h2, h3, h4, h5, h6 { line-height: 1.2; } From e91747785b1950598812348ce9b8da89c7da9e22 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 20 Apr 2026 15:42:28 +0100 Subject: [PATCH 29/69] PDF: Started building system to allow custom DOMPDF font loading --- app/Config/exports.php | 4 +-- app/Exports/PdfGenerator.php | 47 +++++++++++++++++++++++++++ storage/fonts/.gitignore | 6 +++- storage/fonts/dompdf/.gitignore | 3 ++ storage/fonts/dompdf/cache/.gitignore | 2 ++ 5 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 storage/fonts/dompdf/.gitignore create mode 100644 storage/fonts/dompdf/cache/.gitignore diff --git a/app/Config/exports.php b/app/Config/exports.php index 2e22bc759..f48fe0a67 100644 --- a/app/Config/exports.php +++ b/app/Config/exports.php @@ -68,7 +68,7 @@ return [ * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic, * Symbol, ZapfDingbats. */ - 'font_dir' => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782) + 'font_dir' => storage_path('fonts/dompdf'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782) /** * The location of the DOMPDF font cache directory. @@ -78,7 +78,7 @@ return [ * * Note: This directory must exist and be writable by the webserver process. */ - 'font_cache' => storage_path('fonts/'), + 'font_cache' => storage_path('fonts/dompdf/cache'), /** * The location of a temporary directory. diff --git a/app/Exports/PdfGenerator.php b/app/Exports/PdfGenerator.php index f31d8aad0..df40bf44f 100644 --- a/app/Exports/PdfGenerator.php +++ b/app/Exports/PdfGenerator.php @@ -4,6 +4,8 @@ namespace BookStack\Exports; use BookStack\Exceptions\PdfExportException; use Dompdf\Dompdf; +use FontLib\Font; +use Illuminate\Support\Str; use Knp\Snappy\Pdf as SnappyPdf; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; @@ -60,12 +62,57 @@ class PdfGenerator $domPdf = new Dompdf($options); $domPdf->setBasePath(base_path('public')); + $fontMetrics = $domPdf->getFontMetrics(); + $userFontfamilies = $this->getUserDomPdfFontFamilies(); + foreach ($userFontfamilies as $fontFamily => $fonts) { + $fontMetrics->setFontFamily($fontFamily, $fonts); + } + +// dd($userFontfamilies, $fontMetrics->getFontFamilies()); $domPdf->loadHTML($this->convertEntities($html)); $domPdf->render(); return (string) $domPdf->output(); } + /** + * @return array> + */ + protected function getUserDomPdfFontFamilies(): array + { + $fontStore = storage_path('fonts/dompdf'); + if (!is_dir($fontStore)) { + return []; + } + + $fontFamilies = []; + $fontFiles = glob($fontStore . DIRECTORY_SEPARATOR . '*.ttf'); + foreach ($fontFiles as $fontFile) { + $fontFileName = basename($fontFile, '.ttf'); + $expectedUfm = $fontStore . DIRECTORY_SEPARATOR . $fontFileName . '.ufm'; + if (!file_exists($expectedUfm)) { + $font = Font::load($fontFile); + $font->parse(); + $font->saveAdobeFontMetrics($expectedUfm); + } + + $nameParts = explode('-', $fontFileName); + if (count($nameParts) === 1 || $nameParts[1] === 'Regular') { + $nameParts[1] = 'Normal'; + } + + $family = trim(strtolower(preg_replace('/([A-Z])/', ' $1', $nameParts[0]))); + $variation = Str::snake($nameParts[1]); + if (!isset($fontFamilies[$family])) { + $fontFamilies[$family] = []; + } + + $fontFamilies[$family][$variation] = $fontStore . DIRECTORY_SEPARATOR . $fontFileName; + } + + return $fontFamilies; + } + /** * @throws PdfExportException */ diff --git a/storage/fonts/.gitignore b/storage/fonts/.gitignore index c96a04f00..cb0b47dac 100755 --- a/storage/fonts/.gitignore +++ b/storage/fonts/.gitignore @@ -1,2 +1,6 @@ +# Font cache files have once been stored directly in this folder +# therefore its important the contents non-ignored by git +# are chosen selectively * -!.gitignore \ No newline at end of file +!.gitignore +!dompdf/ \ No newline at end of file diff --git a/storage/fonts/dompdf/.gitignore b/storage/fonts/dompdf/.gitignore new file mode 100644 index 000000000..23ef65311 --- /dev/null +++ b/storage/fonts/dompdf/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!cache/ \ No newline at end of file diff --git a/storage/fonts/dompdf/cache/.gitignore b/storage/fonts/dompdf/cache/.gitignore new file mode 100644 index 000000000..c96a04f00 --- /dev/null +++ b/storage/fonts/dompdf/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file From 241563e8fc272e9e12553462a72238d751ffe179 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Apr 2026 13:12:34 +0100 Subject: [PATCH 30/69] Exports: Added testing coverage for DOMPDF font usage --- app/Exports/PdfGenerator.php | 1 - tests/Exports/PdfExportTest.php | 33 +++++++++++++++++++++++++ tests/test-data/fonts/Cardiff-Bold.ttf | Bin 0 -> 66940 bytes tests/test-data/fonts/Cardiff.ttf | Bin 0 -> 67984 bytes tests/test-data/fonts/attribution.txt | 2 ++ 5 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/test-data/fonts/Cardiff-Bold.ttf create mode 100644 tests/test-data/fonts/Cardiff.ttf create mode 100644 tests/test-data/fonts/attribution.txt diff --git a/app/Exports/PdfGenerator.php b/app/Exports/PdfGenerator.php index df40bf44f..10f0624cf 100644 --- a/app/Exports/PdfGenerator.php +++ b/app/Exports/PdfGenerator.php @@ -68,7 +68,6 @@ class PdfGenerator $fontMetrics->setFontFamily($fontFamily, $fonts); } -// dd($userFontfamilies, $fontMetrics->getFontFamilies()); $domPdf->loadHTML($this->convertEntities($html)); $domPdf->render(); diff --git a/tests/Exports/PdfExportTest.php b/tests/Exports/PdfExportTest.php index f311f8457..78da3b0c2 100644 --- a/tests/Exports/PdfExportTest.php +++ b/tests/Exports/PdfExportTest.php @@ -79,6 +79,39 @@ class PdfExportTest extends TestCase $this->assertStringContainsString('
    entities->page()->forceFill([ + 'html' => '

    Boldtext

    ', + ]); + $page->save(); + $this->setSettings([ + 'app-custom-head' => '' + ]); + $normalFont = $this->files->testFilePath('fonts/Cardiff.ttf'); + $normalFontTarget = storage_path('fonts/dompdf/MeowWords.ttf'); + $boldFont = $this->files->testFilePath('fonts/Cardiff-Bold.ttf'); + $boldFontTarget = storage_path('fonts/dompdf/MeowWords-Bold.ttf'); + copy($normalFont, $normalFontTarget); + copy($boldFont, $boldFontTarget); + + $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); + $resp->assertStatus(200); + + // Existance of UFM files indicates the metrics have been generated + $this->assertFileExists(storage_path('fonts/dompdf/MeowWords.ufm')); + $this->assertFileExists(storage_path('fonts/dompdf/MeowWords-Bold.ufm')); + // Existence of cache json files indicates the fonts have been used + $this->assertFileExists(storage_path('fonts/dompdf/cache/MeowWords.ufm.json')); + $this->assertFileExists(storage_path('fonts/dompdf/cache/MeowWords-Bold.ufm.json')); + + $filesToCleanUp = [...glob(storage_path('fonts/dompdf/Meow*')), ...glob(storage_path('fonts/dompdf/cache/Meow*'))]; + foreach ($filesToCleanUp as $file) { + unlink($file); + } + } + public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true() { $page = $this->entities->page(); diff --git a/tests/test-data/fonts/Cardiff-Bold.ttf b/tests/test-data/fonts/Cardiff-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..efaae4d9eb03a58d47a8f096ee53c1fb8c918664 GIT binary patch literal 66940 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 T)!tky7+*wA1H^|--Q)iNX_HIK literal 0 HcmV?d00001 diff --git a/tests/test-data/fonts/Cardiff.ttf b/tests/test-data/fonts/Cardiff.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4e8fea609c6b15e512542d46f8d7f53dcbb32c99 GIT binary patch literal 67984 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 h`ue*swe Date: Wed, 22 Apr 2026 13:22:20 +0100 Subject: [PATCH 31/69] Exports: Improved dompdf font loading permission errors --- app/Exports/PdfGenerator.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/Exports/PdfGenerator.php b/app/Exports/PdfGenerator.php index 10f0624cf..5506fe740 100644 --- a/app/Exports/PdfGenerator.php +++ b/app/Exports/PdfGenerator.php @@ -65,7 +65,12 @@ class PdfGenerator $fontMetrics = $domPdf->getFontMetrics(); $userFontfamilies = $this->getUserDomPdfFontFamilies(); foreach ($userFontfamilies as $fontFamily => $fonts) { - $fontMetrics->setFontFamily($fontFamily, $fonts); + try { + $fontMetrics->setFontFamily($fontFamily, $fonts); + } catch (\Exception $exception) { + $expectedPath = storage_path('fonts/dompdf'); + throw new PdfExportException("Failed to create required font data in {$expectedPath}, Ensure all content in this location is writable by the web server"); + } } $domPdf->loadHTML($this->convertEntities($html)); @@ -92,7 +97,11 @@ class PdfGenerator if (!file_exists($expectedUfm)) { $font = Font::load($fontFile); $font->parse(); - $font->saveAdobeFontMetrics($expectedUfm); + try { + $font->saveAdobeFontMetrics($expectedUfm); + } catch (\Exception $exception) { + throw new PdfExportException("Failed to create required font data at $expectedUfm, Ensure this location is writable by the web server"); + } } $nameParts = explode('-', $fontFileName); From 74aa897626e649cfa17b0c07c5fc1b985624e20a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 24 Apr 2026 23:16:44 +0100 Subject: [PATCH 32/69] Readme: Updated netways sponsor link --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index d3a408ad0..340545dcc 100644 --- a/readme.md +++ b/readme.md @@ -72,7 +72,7 @@ Big thanks to these companies for supporting the project. Stellar Hosted - + NETWAYS Web Services From a37f903dc7e48da434947480917829af571721ab Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Apr 2026 12:07:43 +0100 Subject: [PATCH 33/69] CI: Migrated workflows to forgejo --- .forgejo/FUNDING.yml | 4 ++ .../workflows/analyse-php.yml | 17 ++++++--- {.github => .forgejo}/workflows/lint-js.yml | 7 +++- {.github => .forgejo}/workflows/lint-php.yml | 11 ++++-- {.github => .forgejo}/workflows/test-js.yml | 7 +++- .../workflows/test-migrations.yml | 38 +++++++++++-------- {.github => .forgejo}/workflows/test-php.yml | 36 ++++++++++-------- 7 files changed, 76 insertions(+), 44 deletions(-) create mode 100644 .forgejo/FUNDING.yml rename {.github => .forgejo}/workflows/analyse-php.yml (64%) rename {.github => .forgejo}/workflows/lint-js.yml (69%) rename {.github => .forgejo}/workflows/lint-php.yml (57%) rename {.github => .forgejo}/workflows/test-js.yml (74%) rename {.github => .forgejo}/workflows/test-migrations.yml (65%) rename {.github => .forgejo}/workflows/test-php.yml (64%) diff --git a/.forgejo/FUNDING.yml b/.forgejo/FUNDING.yml new file mode 100644 index 000000000..5c50c3f69 --- /dev/null +++ b/.forgejo/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: [ssddanbrown] +ko_fi: ssddanbrown diff --git a/.github/workflows/analyse-php.yml b/.forgejo/workflows/analyse-php.yml similarity index 64% rename from .github/workflows/analyse-php.yml rename to .forgejo/workflows/analyse-php.yml index 647835aeb..1214c39fb 100644 --- a/.github/workflows/analyse-php.yml +++ b/.forgejo/workflows/analyse-php.yml @@ -1,6 +1,7 @@ name: analyse-php on: + workflow_dispatch: push: paths: - '**.php' @@ -11,14 +12,16 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-24.04 + runs-on: docker + container: + image: node:24-bullseye steps: - - uses: actions/checkout@v4 + - uses: https://code.forgejo.org/actions/checkout@v6 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: https://github.com/shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.5 extensions: gd, mbstring, json, curl, xml, mysql, ldap - name: Get Composer Cache Directory @@ -27,14 +30,16 @@ jobs: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer packages - uses: actions/cache@v4 + uses: https://code.forgejo.org/actions/cache@v5 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-8.3 + key: ${{ runner.os }}-composer-8.5 restore-keys: ${{ runner.os }}-composer- - name: Install composer dependencies run: composer install --prefer-dist --no-interaction --ansi + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}' - name: Run static analysis check run: composer check-static diff --git a/.github/workflows/lint-js.yml b/.forgejo/workflows/lint-js.yml similarity index 69% rename from .github/workflows/lint-js.yml rename to .forgejo/workflows/lint-js.yml index 9aceea2a2..cef1d0540 100644 --- a/.github/workflows/lint-js.yml +++ b/.forgejo/workflows/lint-js.yml @@ -1,6 +1,7 @@ name: lint-js on: + workflow_dispatch: push: paths: - '**.js' @@ -13,9 +14,11 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-24.04 + runs-on: docker + container: + image: node:24-bullseye steps: - - uses: actions/checkout@v4 + - uses: https://code.forgejo.org/actions/checkout@v6 - name: Install NPM deps run: npm ci diff --git a/.github/workflows/lint-php.yml b/.forgejo/workflows/lint-php.yml similarity index 57% rename from .github/workflows/lint-php.yml rename to .forgejo/workflows/lint-php.yml index cb9dedcb2..abebcc5ec 100644 --- a/.github/workflows/lint-php.yml +++ b/.forgejo/workflows/lint-php.yml @@ -1,6 +1,7 @@ name: lint-php on: + workflow_dispatch: push: paths: - '**.php' @@ -11,14 +12,16 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-24.04 + runs-on: docker + container: + image: node:24-bullseye steps: - - uses: actions/checkout@v4 + - uses: https://code.forgejo.org/actions/checkout@v6 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: https://github.com/shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.5 tools: phpcs - name: Run formatting check diff --git a/.github/workflows/test-js.yml b/.forgejo/workflows/test-js.yml similarity index 74% rename from .github/workflows/test-js.yml rename to .forgejo/workflows/test-js.yml index 379f1ebfa..6fa21ee27 100644 --- a/.github/workflows/test-js.yml +++ b/.forgejo/workflows/test-js.yml @@ -1,6 +1,7 @@ name: test-js on: + workflow_dispatch: push: paths: - '**.js' @@ -15,9 +16,11 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-24.04 + runs-on: docker + container: + image: node:24-bullseye steps: - - uses: actions/checkout@v6 + - uses: https://code.forgejo.org/actions/checkout@v6 - name: Install NPM deps run: npm ci diff --git a/.github/workflows/test-migrations.yml b/.forgejo/workflows/test-migrations.yml similarity index 65% rename from .github/workflows/test-migrations.yml rename to .forgejo/workflows/test-migrations.yml index 80075c3f7..7348ff2b3 100644 --- a/.github/workflows/test-migrations.yml +++ b/.forgejo/workflows/test-migrations.yml @@ -1,6 +1,7 @@ name: test-migrations on: + workflow_dispatch: push: paths: - '**.php' @@ -13,15 +14,25 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-24.04 + runs-on: docker + container: + image: node:24-bullseye strategy: matrix: php: ['8.2', '8.3', '8.4', '8.5'] + services: + mysql: + image: docker.io/library/mariadb:12.2.2-noble + env: + MARIADB_USER: bookstack-test + MARIADB_PASSWORD: bookstack-test + MARIADB_DATABASE: bookstack-test + MARIADB_ROOT_PASSWORD: password steps: - - uses: actions/checkout@v4 + - uses: https://code.forgejo.org/actions/checkout@v6 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: https://github.com/shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: gd, mbstring, json, curl, xml, mysql, ldap @@ -32,34 +43,31 @@ jobs: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer packages - uses: actions/cache@v4 + uses: https://code.forgejo.org/actions/cache@v5 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ matrix.php }} restore-keys: ${{ runner.os }}-composer- - - name: Start MySQL - run: | - sudo systemctl start mysql - - - name: Create database & user - run: | - mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;' - mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';" - mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';" - mysql -uroot -proot -e 'FLUSH PRIVILEGES;' - - name: Install composer dependencies run: composer install --prefer-dist --no-interaction --ansi + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}' - name: Start migration test + env: + DB_HOST: mysql run: | php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing - name: Start migration:rollback test + env: + DB_HOST: mysql run: | php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing - name: Start migration rerun test + env: + DB_HOST: mysql run: | php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing diff --git a/.github/workflows/test-php.yml b/.forgejo/workflows/test-php.yml similarity index 64% rename from .github/workflows/test-php.yml rename to .forgejo/workflows/test-php.yml index 5f4c16caf..0fc39d9fa 100644 --- a/.github/workflows/test-php.yml +++ b/.forgejo/workflows/test-php.yml @@ -1,6 +1,7 @@ name: test-php on: + workflow_dispatch: push: paths: - '**.php' @@ -13,15 +14,25 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-24.04 + runs-on: docker + container: + image: node:24-bullseye strategy: matrix: php: ['8.2', '8.3', '8.4', '8.5'] + services: + mysql: + image: docker.io/library/mariadb:12.2.2-noble + env: + MARIADB_USER: bookstack-test + MARIADB_PASSWORD: bookstack-test + MARIADB_DATABASE: bookstack-test + MARIADB_ROOT_PASSWORD: password steps: - - uses: actions/checkout@v4 + - uses: https://code.forgejo.org/actions/checkout@v6 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: https://github.com/shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp @@ -32,30 +43,25 @@ jobs: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer packages - uses: actions/cache@v4 + uses: https://code.forgejo.org/actions/cache@v5 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ matrix.php }} restore-keys: ${{ runner.os }}-composer- - - name: Start Database - run: | - sudo systemctl start mysql - - - name: Setup Database - run: | - mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;' - mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';" - mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';" - mysql -uroot -proot -e 'FLUSH PRIVILEGES;' - - name: Install composer dependencies run: composer install --prefer-dist --no-interaction --ansi + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}' - name: Migrate and seed the database + env: + DB_HOST: mysql run: | php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing - name: Run PHP tests + env: + DB_HOST: mysql run: php${{ matrix.php }} ./vendor/bin/phpunit From 0f59981932ea927d52c490537e0eb55f4e351d0a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Apr 2026 12:52:05 +0100 Subject: [PATCH 34/69] CI: Updated tests using DB to set test DB URL --- .forgejo/workflows/test-migrations.yml | 6 +++--- .forgejo/workflows/test-php.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/test-migrations.yml b/.forgejo/workflows/test-migrations.yml index 7348ff2b3..089dbd67a 100644 --- a/.forgejo/workflows/test-migrations.yml +++ b/.forgejo/workflows/test-migrations.yml @@ -56,18 +56,18 @@ jobs: - name: Start migration test env: - DB_HOST: mysql + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test' run: | php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing - name: Start migration:rollback test env: - DB_HOST: mysql + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test' run: | php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing - name: Start migration rerun test env: - DB_HOST: mysql + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test' run: | php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing diff --git a/.forgejo/workflows/test-php.yml b/.forgejo/workflows/test-php.yml index 0fc39d9fa..a06cdfa66 100644 --- a/.forgejo/workflows/test-php.yml +++ b/.forgejo/workflows/test-php.yml @@ -56,12 +56,12 @@ jobs: - name: Migrate and seed the database env: - DB_HOST: mysql + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test' run: | php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing - name: Run PHP tests env: - DB_HOST: mysql + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test' run: php${{ matrix.php }} ./vendor/bin/phpunit From cc6e9e0546f2663d40a51039483d94d9814a57c7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Apr 2026 13:17:58 +0100 Subject: [PATCH 35/69] CI: Attempt a more robust avif support check --- tests/Uploads/ImageTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 1088e657e..1bccee2c3 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -75,7 +75,7 @@ class ImageTest extends TestCase public function test_image_display_thumbnail_generation_for_animated_avif_images_uses_original_file() { - if (! function_exists('imageavif')) { + if ((gd_info()['AVIF Support'] ?? false) !== true) { $this->markTestSkipped('imageavif() is not available'); } From 2e2f59fa0f63d40be748ec905c29e76c16738ebd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Apr 2026 13:36:47 +0100 Subject: [PATCH 36/69] CI: Updated images to debian trixie --- .forgejo/workflows/analyse-php.yml | 2 +- .forgejo/workflows/lint-js.yml | 2 +- .forgejo/workflows/lint-php.yml | 2 +- .forgejo/workflows/test-js.yml | 2 +- .forgejo/workflows/test-migrations.yml | 2 +- .forgejo/workflows/test-php.yml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.forgejo/workflows/analyse-php.yml b/.forgejo/workflows/analyse-php.yml index 1214c39fb..8975d6e52 100644 --- a/.forgejo/workflows/analyse-php.yml +++ b/.forgejo/workflows/analyse-php.yml @@ -14,7 +14,7 @@ jobs: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: docker container: - image: node:24-bullseye + image: docker.io/library/node:24-trixie steps: - uses: https://code.forgejo.org/actions/checkout@v6 diff --git a/.forgejo/workflows/lint-js.yml b/.forgejo/workflows/lint-js.yml index cef1d0540..5cacec67a 100644 --- a/.forgejo/workflows/lint-js.yml +++ b/.forgejo/workflows/lint-js.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: docker container: - image: node:24-bullseye + image: docker.io/library/node:24-trixie steps: - uses: https://code.forgejo.org/actions/checkout@v6 diff --git a/.forgejo/workflows/lint-php.yml b/.forgejo/workflows/lint-php.yml index abebcc5ec..b409c62e2 100644 --- a/.forgejo/workflows/lint-php.yml +++ b/.forgejo/workflows/lint-php.yml @@ -14,7 +14,7 @@ jobs: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: docker container: - image: node:24-bullseye + image: docker.io/library/node:24-trixie steps: - uses: https://code.forgejo.org/actions/checkout@v6 diff --git a/.forgejo/workflows/test-js.yml b/.forgejo/workflows/test-js.yml index 6fa21ee27..180e6d545 100644 --- a/.forgejo/workflows/test-js.yml +++ b/.forgejo/workflows/test-js.yml @@ -18,7 +18,7 @@ jobs: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: docker container: - image: node:24-bullseye + image: docker.io/library/node:24-trixie steps: - uses: https://code.forgejo.org/actions/checkout@v6 diff --git a/.forgejo/workflows/test-migrations.yml b/.forgejo/workflows/test-migrations.yml index 089dbd67a..e969d3e47 100644 --- a/.forgejo/workflows/test-migrations.yml +++ b/.forgejo/workflows/test-migrations.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: docker container: - image: node:24-bullseye + image: docker.io/library/node:24-trixie strategy: matrix: php: ['8.2', '8.3', '8.4', '8.5'] diff --git a/.forgejo/workflows/test-php.yml b/.forgejo/workflows/test-php.yml index a06cdfa66..5ff2d14a5 100644 --- a/.forgejo/workflows/test-php.yml +++ b/.forgejo/workflows/test-php.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: docker container: - image: node:24-bullseye + image: docker.io/library/node:24-trixie strategy: matrix: php: ['8.2', '8.3', '8.4', '8.5'] From c1610c453298770db4c1100863f0aefb55a78eb3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Apr 2026 17:48:27 +0100 Subject: [PATCH 37/69] Meta: Migrated repo content to forgejo Kept some GitHub templates with warnings about the migration. Made some initial updates to readme for the migration. --- .forgejo/CODE_OF_CONDUCT.md | 2 ++ .../ISSUE_TEMPLATE/api_request.yml | 0 {.github => .forgejo}/ISSUE_TEMPLATE/bug_report.yml | 0 .forgejo/ISSUE_TEMPLATE/config.yml | 13 +++++++++++++ .../ISSUE_TEMPLATE/feature_request.yml | 2 +- .../ISSUE_TEMPLATE/language_request.yml | 0 .../ISSUE_TEMPLATE/support_request.yml | 6 +++--- .../ISSUE_TEMPLATE/z_blank_request.yml | 0 {.github => .forgejo}/SECURITY.md | 0 .forgejo/pull_request_template.md | 11 +++++++++++ .github/ISSUE_TEMPLATE/config.yml | 6 +++--- .github/pull_request_template.md | 13 ++++++------- readme.md | 9 ++++----- 13 files changed, 43 insertions(+), 19 deletions(-) create mode 100644 .forgejo/CODE_OF_CONDUCT.md rename {.github => .forgejo}/ISSUE_TEMPLATE/api_request.yml (100%) rename {.github => .forgejo}/ISSUE_TEMPLATE/bug_report.yml (100%) create mode 100644 .forgejo/ISSUE_TEMPLATE/config.yml rename {.github => .forgejo}/ISSUE_TEMPLATE/feature_request.yml (93%) rename {.github => .forgejo}/ISSUE_TEMPLATE/language_request.yml (100%) rename {.github => .forgejo}/ISSUE_TEMPLATE/support_request.yml (91%) rename {.github => .forgejo}/ISSUE_TEMPLATE/z_blank_request.yml (100%) rename {.github => .forgejo}/SECURITY.md (100%) create mode 100644 .forgejo/pull_request_template.md diff --git a/.forgejo/CODE_OF_CONDUCT.md b/.forgejo/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..7a0265672 --- /dev/null +++ b/.forgejo/CODE_OF_CONDUCT.md @@ -0,0 +1,2 @@ +Please find our community rules on our website here: +https://www.bookstackapp.com/about/community-rules/ \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/api_request.yml b/.forgejo/ISSUE_TEMPLATE/api_request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/api_request.yml rename to .forgejo/ISSUE_TEMPLATE/api_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.forgejo/ISSUE_TEMPLATE/bug_report.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/bug_report.yml rename to .forgejo/ISSUE_TEMPLATE/bug_report.yml diff --git a/.forgejo/ISSUE_TEMPLATE/config.yml b/.forgejo/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..a72fb1ef4 --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,13 @@ +blank_issues_enabled: false +contact_links: + - name: Community Forum Support + url: https://community.bookstackapp.com + about: Get support by talking with the BookStack team & community. + + - name: Debugging & Common Issues + url: https://www.bookstackapp.com/docs/admin/debugging/ + about: Find details on how to debug issues and view common issues with their resolutions. + + - name: Official Support Plans + url: https://www.bookstackapp.com/support/ + about: View our official support plans that offer assured support for business. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.forgejo/ISSUE_TEMPLATE/feature_request.yml similarity index 93% rename from .github/ISSUE_TEMPLATE/feature_request.yml rename to .forgejo/ISSUE_TEMPLATE/feature_request.yml index ca1f2b830..c1420cb19 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.forgejo/ISSUE_TEMPLATE/feature_request.yml @@ -33,7 +33,7 @@ body: attributes: label: Have you searched for an existing open/closed issue? description: | - To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundamental benefit/goal of your request. + To help us keep these issues under control, please ensure you have first [searched our issue list](https://codeberg.org/bookstack/bookstack/issues) for any existing issues that cover the fundamental benefit/goal of your request. options: - label: I have searched for existing issues and none cover my fundamental request required: true diff --git a/.github/ISSUE_TEMPLATE/language_request.yml b/.forgejo/ISSUE_TEMPLATE/language_request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/language_request.yml rename to .forgejo/ISSUE_TEMPLATE/language_request.yml diff --git a/.github/ISSUE_TEMPLATE/support_request.yml b/.forgejo/ISSUE_TEMPLATE/support_request.yml similarity index 91% rename from .github/ISSUE_TEMPLATE/support_request.yml rename to .forgejo/ISSUE_TEMPLATE/support_request.yml index ae808a0b6..d60eab711 100644 --- a/.github/ISSUE_TEMPLATE/support_request.yml +++ b/.forgejo/ISSUE_TEMPLATE/support_request.yml @@ -15,11 +15,11 @@ body: - type: checkboxes id: searchissue attributes: - label: Searched GitHub Issues + label: Searched Existing Issues description: | - I have searched for the issue and potential resolutions within the [project's GitHub issue list](https://github.com/BookStackApp/BookStack/issues) + I have searched for the issue and potential resolutions within the [project's issue list](https://codeberg.org/bookstack/bookstack/issues) options: - - label: I have searched GitHub for the issue. + - label: I have searched for the issue. required: true - type: textarea id: scenario diff --git a/.github/ISSUE_TEMPLATE/z_blank_request.yml b/.forgejo/ISSUE_TEMPLATE/z_blank_request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/z_blank_request.yml rename to .forgejo/ISSUE_TEMPLATE/z_blank_request.yml diff --git a/.github/SECURITY.md b/.forgejo/SECURITY.md similarity index 100% rename from .github/SECURITY.md rename to .forgejo/SECURITY.md diff --git a/.forgejo/pull_request_template.md b/.forgejo/pull_request_template.md new file mode 100644 index 000000000..70f105874 --- /dev/null +++ b/.forgejo/pull_request_template.md @@ -0,0 +1,11 @@ +## Details + + + + +## Checklist + + + +- [ ] I have read the [BookStack community rules](https://www.bookstackapp.com/about/community-rules/). +- [ ] This PR does not feature significant use of LLM/AI generation as per the community rules above. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 019667388..0cd657d7a 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - - name: Discord Chat Support - url: https://discord.gg/ztkBqR2 - about: Realtime support & chat with the BookStack community and the team. + - name: Open Issues Here Instead + url: https://codeberg.org/bookstack/bookstack/issues + about: This project has migrated to Codeberg, please open issues there instead. - name: Debugging & Common Issues url: https://www.bookstackapp.com/docs/admin/debugging/ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 70f105874..c185f3280 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,10 @@ -## Details +**Warning:** - - +This project has migrated to Codeberg: +https://codeberg.org/bookstack/bookstack -## Checklist +Please open pull requests here instead. - +ANY PULL REQUESTS OPENED HERE WILL BE CLOSED WITHOUT COMMENT OR MERGE. -- [ ] I have read the [BookStack community rules](https://www.bookstackapp.com/about/community-rules/). -- [ ] This PR does not feature significant use of LLM/AI generation as per the community rules above. +--- \ No newline at end of file diff --git a/readme.md b/readme.md index 340545dcc..2cc24c52e 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # BookStack -[![GitHub release](https://img.shields.io/github/release/BookStackApp/BookStack.svg)](https://github.com/BookStackApp/BookStack/releases/latest) +[![Codeberg release](https://img.shields.io/github/release/BookStackApp/BookStack.svg)](https://github.com/BookStackApp/BookStack/releases/latest) [![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/development/LICENSE) [![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack) [![Build Status](https://github.com/BookStackApp/BookStack/workflows/test-php/badge.svg)](https://github.com/BookStackApp/BookStack/actions) @@ -11,7 +11,6 @@ [![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/) [![Community Discussions](https://img.shields.io/static/v1?label=Community&message=Discussions&color=4d36c4&logo=zulip)](https://community.bookstackapp.com/) [![Mastodon](https://img.shields.io/static/v1?label=Mastodon&message=@bookstack&color=595aff&logo=mastodon)](https://www.bookstackapp.com/links/mastodon) -[![Discord](https://img.shields.io/static/v1?label=Discord&message=chat&color=738adb&logo=discord)](https://www.bookstackapp.com/links/discord)
    [![PeerTube](https://img.shields.io/static/v1?label=PeerTube&message=bookstack@foss.video&color=f2690d&logo=peertube)](https://foss.video/c/bookstack) [![YouTube](https://img.shields.io/static/v1?label=YouTube&message=bookstackapp&color=ff0000&logo=youtube)](https://www.youtube.com/bookstackapp) @@ -23,7 +22,7 @@ A platform for storing and organising information and documentation. Details for * [Demo Instance](https://demo.bookstackapp.com) * [Screenshots](https://www.bookstackapp.com/#screenshots) * [BookStack Blog](https://www.bookstackapp.com/blog) -* [Issue List](https://github.com/BookStackApp/BookStack/issues) +* [Issue List](https://codeberg.org/bookstack/bookstack/issues) * [Community Discussions](https://community.bookstackapp.com/) * [Support Options](https://www.bookstackapp.com/support/) @@ -134,7 +133,7 @@ Security information for administering a BookStack instance can be found on the If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates). -If you would like to report a security concern, details of doing so [can be found here](https://github.com/BookStackApp/BookStack/blob/development/.github/SECURITY.md). +If you would like to report a security concern, details of doing so [can be found here](/.forgejo/SECURITY.md). ## ♿ Accessibility @@ -142,7 +141,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://codeberg.org/bookstack/website) repo. +The website which contains the project docs & blog can be found in the [bookstack/website](https://codeberg.org/bookstack/website) repo. ## ⚖️ License From 7c1d30bc8fc1464b7bcbeae3e70fd91d672759f7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Apr 2026 20:56:05 +0100 Subject: [PATCH 38/69] Translations: Added crowdin workflow action --- .forgejo/workflows/sync-translations.yml | 36 ++++++++++++++++++++++++ crowdin.yml | 5 +++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .forgejo/workflows/sync-translations.yml diff --git a/.forgejo/workflows/sync-translations.yml b/.forgejo/workflows/sync-translations.yml new file mode 100644 index 000000000..5ff760220 --- /dev/null +++ b/.forgejo/workflows/sync-translations.yml @@ -0,0 +1,36 @@ +name: Crowdin Action + +on: + push: + branches: [ development ] + paths: + - 'lang/**.php' + schedule: + - cron: '30 4 * * *' + workflow_dispatch: + +jobs: + synchronize-with-crowdin: + runs-on: docker + container: + image: docker.io/library/node:24-trixie + + steps: + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v6 + + - name: crowdin action + uses: https://github.com/crowdin/github-action@v2 + with: + upload_sources: true + upload_translations: false + download_translations: true + localization_branch_name: l10n_development + create_pull_request: false + github_base_url: https://codeberg.org + env: + # A numeric ID, found at https://crowdin.com/project//tools/api + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + + # Visit https://crowdin.com/settings#api-key to create this token + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} \ No newline at end of file diff --git a/crowdin.yml b/crowdin.yml index b803b07ee..53869eabd 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,10 +1,13 @@ project_id: "377219" project_identifier: bookstack +api_token_env: CROWDIN_PERSONAL_TOKEN + base_path: . preserve_hierarchy: false pull_request_title: Updated translations with latest Crowdin changes pull_request_labels: - - ":earth_africa: Translations" + - "Translations" + files: - source: /lang/en/*.php translation: /lang/%two_letters_code%/%original_file_name% From 24e6087ef8b54174e851dfd356632abad37ff551 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Apr 2026 21:13:05 +0100 Subject: [PATCH 39/69] Meta: Updated readme shields and fixed workflow value --- .forgejo/workflows/sync-translations.yml | 5 +---- readme.md | 10 +++++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.forgejo/workflows/sync-translations.yml b/.forgejo/workflows/sync-translations.yml index 5ff760220..a0c09ba8f 100644 --- a/.forgejo/workflows/sync-translations.yml +++ b/.forgejo/workflows/sync-translations.yml @@ -27,10 +27,7 @@ jobs: download_translations: true localization_branch_name: l10n_development create_pull_request: false - github_base_url: https://codeberg.org + github_base_url: codeberg.org env: - # A numeric ID, found at https://crowdin.com/project//tools/api CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - - # Visit https://crowdin.com/settings#api-key to create this token CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} \ No newline at end of file diff --git a/readme.md b/readme.md index 2cc24c52e..7bb71949d 100644 --- a/readme.md +++ b/readme.md @@ -1,14 +1,14 @@ # BookStack -[![Codeberg release](https://img.shields.io/github/release/BookStackApp/BookStack.svg)](https://github.com/BookStackApp/BookStack/releases/latest) -[![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/development/LICENSE) +[![Codeberg release](https://img.shields.io/gitea/v/release/bookstack/bookstack.svg?gitea_url=https://codeberg.org)](https://codeberg.org/bookstack/bookstack/releases/latest) +[![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://codeberg.org/bookstack/bookstack/src/branch/development/LICENSE) [![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack) -[![Build Status](https://github.com/BookStackApp/BookStack/workflows/test-php/badge.svg)](https://github.com/BookStackApp/BookStack/actions) -[![Lint Status](https://github.com/BookStackApp/BookStack/workflows/lint-php/badge.svg)](https://github.com/BookStackApp/BookStack/actions) +[![Build Status](https://codeberg.org/bookstack/bookstack/badges/workflows/test-php.yml/badge.svg)](https://codeberg.org/bookstack/bookstack/actions?workflow=test-php.yml) +[![Lint Status](https://codeberg.org/bookstack/bookstack/badges/workflows/lint-php.yml/badge.svg)](https://codeberg.org/bookstack/bookstack/actions?workflow=lint-php.yml) [![php-metrics](https://img.shields.io/static/v1?label=Metrics&message=php&color=4F5B93)](https://source.bookstackapp.com/php-stats/index.html)
    [![Alternate Source](https://img.shields.io/static/v1?label=Alt+Source&message=Git&color=ef391a&logo=git)](https://source.bookstackapp.com/) -[![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/) +[![Repo Stats](https://img.shields.io/static/v1?label=Code+Project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/) [![Community Discussions](https://img.shields.io/static/v1?label=Community&message=Discussions&color=4d36c4&logo=zulip)](https://community.bookstackapp.com/) [![Mastodon](https://img.shields.io/static/v1?label=Mastodon&message=@bookstack&color=595aff&logo=mastodon)](https://www.bookstackapp.com/links/mastodon)
    From 55317039acd8c94f0d4bf0a275c865ed618a8f30 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 28 Apr 2026 09:30:48 +0100 Subject: [PATCH 40/69] Meta: Converted GitHub references in codebase to Codeberg --- .forgejo/SECURITY.md | 2 +- app/App/PwaManifestBuilder.php | 2 +- dev/docker/db-testing/Dockerfile | 2 +- dev/docs/logical-theme-system.md | 2 +- dev/docs/release-process.md | 6 +++--- dev/docs/wysiwyg-js-api.md | 2 +- readme.md | 11 +++++------ .../views/api-docs/parts/getting-started.blade.php | 4 ++-- resources/views/errors/debug.blade.php | 6 +++--- resources/views/settings/layout.blade.php | 2 +- tests/Entity/PageContentTest.php | 2 +- 11 files changed, 20 insertions(+), 21 deletions(-) diff --git a/.forgejo/SECURITY.md b/.forgejo/SECURITY.md index 12e5fe04c..f426079e8 100644 --- a/.forgejo/SECURITY.md +++ b/.forgejo/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -Only the [latest version](https://github.com/BookStackApp/BookStack/releases) of BookStack is supported. +Only the [latest version](https://codeberg.org/bookstack/bookstack/releases) of BookStack is supported. We generally don't support older versions of BookStack due to maintenance effort and since we aim to provide a fairly stable upgrade path for new versions. diff --git a/app/App/PwaManifestBuilder.php b/app/App/PwaManifestBuilder.php index 81ab2fcd7..2dbaead13 100644 --- a/app/App/PwaManifestBuilder.php +++ b/app/App/PwaManifestBuilder.php @@ -10,7 +10,7 @@ class PwaManifestBuilder // does not start a session, so we won't have current user context. // This was attempted but removed since manifest calls could affect user session // history tracking and back redirection. - // Context: https://github.com/BookStackApp/BookStack/issues/4649 + // Context: https://codeberg.org/bookstack/bookstack/issues/4649 $darkMode = (bool) setting()->getForCurrentUser('dark-mode-enabled'); $appName = setting('app-name'); diff --git a/dev/docker/db-testing/Dockerfile b/dev/docker/db-testing/Dockerfile index 618f5bb82..411e2197b 100644 --- a/dev/docker/db-testing/Dockerfile +++ b/dev/docker/db-testing/Dockerfile @@ -18,7 +18,7 @@ ARG BRANCH=development # Download BookStack & install PHP deps RUN mkdir -p /var/www && \ - git clone https://github.com/bookstackapp/bookstack.git --branch "$BRANCH" --single-branch /var/www/bookstack && \ + git clone https://codeberg.org/bookstack/bookstack.git --branch "$BRANCH" --single-branch /var/www/bookstack && \ cd /var/www/bookstack && \ wget https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer -O - -q | php -- --quiet --filename=composer && \ php composer install diff --git a/dev/docs/logical-theme-system.md b/dev/docs/logical-theme-system.md index 9457ca78b..b6342ce54 100644 --- a/dev/docs/logical-theme-system.md +++ b/dev/docs/logical-theme-system.md @@ -74,7 +74,7 @@ Theme::registerCommand(new SayHelloCommand()); ## Available Events -All available events dispatched by BookStack are exposed as static properties on the `\BookStack\Theming\ThemeEvents` class, which can be found within the file `app/Theming/ThemeEvents.php` relative to your root BookStack folder. Alternatively, the events for the latest release can be [seen on GitHub here](https://github.com/BookStackApp/BookStack/blob/release/app/Theming/ThemeEvents.php). +All available events dispatched by BookStack are exposed as static properties on the `\BookStack\Theming\ThemeEvents` class, which can be found within the file `app/Theming/ThemeEvents.php` relative to your root BookStack folder. Alternatively, the events for the latest release can be [seen on Codeberg here](https://codeberg.org/bookstack/bookstack/src/branch/release/app/Theming/ThemeEvents.php). The comments above each constant with the `ThemeEvents.php` file describe the dispatch conditions of the event, in addition to the arguments the action will receive. The comments may also describe any ways the return value of the action may be used. diff --git a/dev/docs/release-process.md b/dev/docs/release-process.md index 758d6db4c..75e9a5ae6 100644 --- a/dev/docs/release-process.md +++ b/dev/docs/release-process.md @@ -12,13 +12,13 @@ Feature releases are generally larger, bringing new features in addition to fixe ### Release Planning Process -Each BookStack release will have a [milestone](https://github.com/BookStackApp/BookStack/milestones) created with issues & pull requests assigned to it to define what will be in that release. Milestones are built up then worked through until complete at which point, after some testing and documentation updates, the release will be deployed. +Each BookStack release will have a [milestone](https://codeberg.org/bookstack/bookstack/milestones) created with issues & pull requests assigned to it to define what will be in that release. Milestones are built up then worked through until complete at which point, after some testing and documentation updates, the release will be deployed. ### Release Announcements -Feature releases, and some patch releases, will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blog posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates). +Feature releases, and some patch releases, will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [Codeberg release page](https://codeberg.org/bookstack/bookstack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blog posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates). ### Release Technical Process Deploying a release, at a high level, simply involves merging the development branch into the release branch before then building & committing any release-only assets. -A helper script [can be found in our](https://github.com/BookStackApp/devops/blob/main/meta-scripts/bookstack-release-steps) devops repo which provides the steps and commands for deploying a new release. \ No newline at end of file +A helper script [can be found in our](https://codeberg.org/bookstack/devops/src/branch/main/meta-scripts/bookstack-release-steps) devops repo which provides the steps and commands for deploying a new release. \ No newline at end of file diff --git a/dev/docs/wysiwyg-js-api.md b/dev/docs/wysiwyg-js-api.md index 4b4fafe56..869bf8c49 100644 --- a/dev/docs/wysiwyg-js-api.md +++ b/dev/docs/wysiwyg-js-api.md @@ -2,7 +2,7 @@ **Warning: This API is currently in development and may change without notice.** -Feedback is very much welcomed via this issue: https://github.com/BookStackApp/BookStack/issues/5937 +Feedback is very much welcomed via this issue: https://codeberg.org/bookstack/bookstack/issues/5937 This document covers the JavaScript API for the (newer Lexical-based) WYSIWYG editor. This API is built and designed to abstract the internals of the editor away diff --git a/readme.md b/readme.md index 7bb71949d..8b81abaee 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,6 @@ [![php-metrics](https://img.shields.io/static/v1?label=Metrics&message=php&color=4F5B93)](https://source.bookstackapp.com/php-stats/index.html)
    [![Alternate Source](https://img.shields.io/static/v1?label=Alt+Source&message=Git&color=ef391a&logo=git)](https://source.bookstackapp.com/) -[![Repo Stats](https://img.shields.io/static/v1?label=Code+Project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/) [![Community Discussions](https://img.shields.io/static/v1?label=Community&message=Discussions&color=4d36c4&logo=zulip)](https://community.bookstackapp.com/) [![Mastodon](https://img.shields.io/static/v1?label=Mastodon&message=@bookstack&color=595aff&logo=mastodon)](https://www.bookstackapp.com/links/mastodon)
    @@ -112,13 +111,13 @@ Translations for text within BookStack are managed through the [BookStack projec Please use [Crowdin](https://crowdin.com/project/bookstack) to contribute translations instead of opening a pull request. The translations within the working codebase can be out-of-date, and merging via code can cause conflicts & sync issues. If for some reason you can't use Crowdin feel free to open an issue to discuss alternative options. -If you'd like a new language to be added to Crowdin, for you to be able to provide translations for, please [open a new issue here](https://github.com/BookStackApp/BookStack/issues/new?template=language_request.yml). +If you'd like a new language to be added to Crowdin, for you to be able to provide translations for, please [open a new issue here](https://codeberg.org/bookstack/bookstack/issues/new?template=.forgejo%2fISSUE_TEMPLATE%2flanguage_request.yml). -Please note, translations in BookStack are provided to the "Crowdin Global Translation Memory" which helps BookStack and other projects with finding translations. If you are not happy with contributing to this then providing translations to BookStack, even manually via GitHub, is not advised. +Please note, translations in BookStack are provided to the "Crowdin Global Translation Memory" which helps BookStack and other projects with finding translations. If you are not happy with contributing to this then providing translations to BookStack, even manually via code, is not advised. ## 🎁 Contributing, Issues & Pull Requests -Feel free to [create issues](https://github.com/BookStackApp/BookStack/issues/new/choose) to request new features or to report bugs & problems. Just please follow the template given when creating the issue. +Feel free to [create issues](https://codeberg.org/bookstack/bookstack/issues/new/choose) to request new features or to report bugs & problems. Just please follow the template given when creating the issue. Pull requests are welcome but, unless it's a small tweak, it may be best to open the pull request early or create an issue for your intended change to discuss how it will fit into the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project. @@ -145,14 +144,14 @@ The website which contains the project docs & blog can be found in the [bookstac ## ⚖️ License -The BookStack source is provided under the [MIT License](https://github.com/BookStackApp/BookStack/blob/development/LICENSE). +The BookStack source is provided under the [MIT License](https://codeberg.org/bookstack/bookstack/src/branch/development/LICENSE). The libraries used by, and included with, BookStack are provided under their own licenses and copyright. The licenses for many of our core dependencies can be found in the attribution list below, but this is not an exhaustive list of all projects used within BookStack. ## 👪 Attribution -The great people that have worked to build and improve BookStack can [be seen here](https://github.com/BookStackApp/BookStack/graphs/contributors). The wonderful people that have provided translations, either through GitHub or via Crowdin [can be seen here](https://github.com/BookStackApp/BookStack/blob/development/.github/translators.txt). +The great people that have worked to build and improve BookStack can [be seen here](https://codeberg.org/bookstack/bookstack/activity/contributors). The wonderful people that have provided translations, either through GitHub, Codeberg or via Crowdin [can be seen here](https://codeberg.org/bookstack/bookstack/src/branch/development/.github/translators.txt). Below are the great open-source projects used to help build BookStack. Note: This is not an exhaustive list of all libraries and projects that would be used in an active BookStack instance. diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index ebe3838ef..8a14befa5 100644 --- a/resources/views/api-docs/parts/getting-started.blade.php +++ b/resources/views/api-docs/parts/getting-started.blade.php @@ -14,11 +14,11 @@ HTTP POST calls upon events occurring in BookStack.
  • - Visual Theme System - + Visual Theme System - Methods to override views, translations and icons within BookStack.
  • - Logical Theme System - + Logical Theme System - Methods to extend back-end functionality within BookStack.
  • diff --git a/resources/views/errors/debug.blade.php b/resources/views/errors/debug.blade.php index e7155431c..969c49595 100644 --- a/resources/views/errors/debug.blade.php +++ b/resources/views/errors/debug.blade.php @@ -113,13 +113,13 @@ Review BookStack debugging documentation »
  • - Ensure your instance is up-to-date » + Ensure your instance is up-to-date »
  • - Search for the issue on GitHub » + Search for the issue on GitHub »
  • - Ask for help via Discord » + Ask for help in our community forums »
  • Search the error message » diff --git a/resources/views/settings/layout.blade.php b/resources/views/settings/layout.blade.php index 930d407a5..2ca06b86a 100644 --- a/resources/views/settings/layout.blade.php +++ b/resources/views/settings/layout.blade.php @@ -18,7 +18,7 @@
    {{ trans('settings.system_version') }}
    - + BookStack @if(!str_starts_with($version, 'v')) version @endif {{ $version }}
    diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index deae153e1..4d97e6b59 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -370,7 +370,7 @@ class PageContentTest extends TestCase public function test_base64_images_within_html_blanked_if_not_supported_extension_for_extract() { - // Relevant to https://github.com/BookStackApp/BookStack/issues/3010 and other cases + // Relevant to https://codeberg.org/bookstack/bookstack/issues/3010 and other cases $extensions = [ 'jiff', 'pngr', 'png ', ' png', '.png', 'png.', 'p.ng', ',png', 'data:image/png', ',data:image/png', From fc220dea3996785a683b2661a711952f53f8da8d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Apr 2026 18:07:32 +0100 Subject: [PATCH 41/69] Search: Fixed exact saerch term negation causing no results Closes #6121 --- app/Search/SearchRunner.php | 10 ++++++++-- tests/Search/EntitySearchTest.php | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index bfb65cf0f..391254172 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -120,8 +120,14 @@ class SearchRunner $filter = function (EloquentBuilder $query) use ($exact) { $inputTerm = str_replace('\\', '\\\\', $exact->value); $query->where('name', 'like', '%' . $inputTerm . '%') - ->orWhere('description', 'like', '%' . $inputTerm . '%') - ->orWhere('text', 'like', '%' . $inputTerm . '%'); + ->orWhere(function (EloquentBuilder $query) use ($inputTerm) { + $query->whereNotNull('description') + ->where('description', 'like', '%' . $inputTerm . '%'); + }) + ->orWhere(function (EloquentBuilder $query) use ($inputTerm) { + $query->whereNotNull('text') + ->where('text', 'like', '%' . $inputTerm . '%'); + }); }; $exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter); diff --git a/tests/Search/EntitySearchTest.php b/tests/Search/EntitySearchTest.php index cb1149dd1..fc300241b 100644 --- a/tests/Search/EntitySearchTest.php +++ b/tests/Search/EntitySearchTest.php @@ -136,17 +136,21 @@ class EntitySearchTest extends TestCase $page->tags()->saveMany([new Tag(['name' => 'DonkCount', 'value' => '500'])]); $page->created_by = $this->users->admin()->id; $page->save(); + $otherPage = $this->entities->newPage(['name' => 'A different page in negation tests', 'html' => '

    A different page in negation tests

    ']); $editor = $this->users->editor(); $this->actingAs($editor); $exactSearch = $this->get('/search?term=' . urlencode('negation -"tortoise"')); $exactSearch->assertStatus(200)->assertDontSeeText($page->name); + $exactSearch->assertSeeText($otherPage->name); $tagSearchA = $this->get('/search?term=' . urlencode('negation [DonkCount=500]')); $tagSearchA->assertStatus(200)->assertSeeText($page->name); + $tagSearchA->assertDontSeeText($otherPage->name); $tagSearchB = $this->get('/search?term=' . urlencode('negation -[DonkCount=500]')); $tagSearchB->assertStatus(200)->assertDontSeeText($page->name); + $tagSearchB->assertSeeText($otherPage->name); $filterSearchA = $this->get('/search?term=' . urlencode('negation -{created_by:me}')); $filterSearchA->assertStatus(200)->assertSeeText($page->name); From 99a704698d1d46451ff14d78abc9d4a836f3f964 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Apr 2026 18:12:24 +0100 Subject: [PATCH 42/69] Deps: Updated PHP package versions --- composer.lock | 254 +++++++++++++++++++++++++------------------------- 1 file changed, 127 insertions(+), 127 deletions(-) diff --git a/composer.lock b/composer.lock index a70aa9a0f..4a56da488 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.376.3", + "version": "3.379.8", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "2081f8db174df4bb8842aed3b7b513590ee9d219" + "reference": "856ddf3d241c29132fe1eb946e112351ab043542" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2081f8db174df4bb8842aed3b7b513590ee9d219", - "reference": "2081f8db174df4bb8842aed3b7b513590ee9d219", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/856ddf3d241c29132fe1eb946e112351ab043542", + "reference": "856ddf3d241c29132fe1eb946e112351ab043542", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.376.3" + "source": "https://github.com/aws/aws-sdk-php/tree/3.379.8" }, - "time": "2026-04-03T18:07:33+00:00" + "time": "2026-04-27T19:13:21+00:00" }, { "name": "bacon/bacon-qr-code", @@ -985,12 +985,12 @@ "version": "v7.0.5", "source": { "type": "git", - "url": "https://github.com/firebase/php-jwt.git", + "url": "https://github.com/googleapis/php-jwt.git", "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", "shasum": "" }, @@ -1039,8 +1039,8 @@ "php" ], "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.5" + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" }, "time": "2026-04-01T20:38:03+00:00" }, @@ -1802,16 +1802,16 @@ }, { "name": "laravel/framework", - "version": "v12.56.0", + "version": "v12.58.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "dac16d424b59debb2273910dde88eb7050a2a709" + "reference": "6172ae1f44ba5d89e111057ee4a4e7c27f5a610d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/dac16d424b59debb2273910dde88eb7050a2a709", - "reference": "dac16d424b59debb2273910dde88eb7050a2a709", + "url": "https://api.github.com/repos/laravel/framework/zipball/6172ae1f44ba5d89e111057ee4a4e7c27f5a610d", + "reference": "6172ae1f44ba5d89e111057ee4a4e7c27f5a610d", "shasum": "" }, "require": { @@ -1852,8 +1852,8 @@ "symfony/mailer": "^7.2.0", "symfony/mime": "^7.2.0", "symfony/polyfill-php83": "^1.33", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", "symfony/uid": "^7.2.0", @@ -2020,20 +2020,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-26T14:51:54+00:00" + "time": "2026-04-26T16:42:04+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.16", + "version": "v0.3.17", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" + "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", - "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", + "url": "https://api.github.com/repos/laravel/prompts/zipball/6a82ac19a28b916ae0885828795dbd4c59d9a818", + "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818", "shasum": "" }, "require": { @@ -2077,22 +2077,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.16" + "source": "https://github.com/laravel/prompts/tree/v0.3.17" }, - "time": "2026-03-23T14:35:33+00:00" + "time": "2026-04-20T16:07:33+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.10", + "version": "v2.0.13", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", "shasum": "" }, "require": { @@ -2140,20 +2140,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-20T19:59:49+00:00" + "time": "2026-04-16T14:03:50+00:00" }, { "name": "laravel/socialite", - "version": "v5.26.1", + "version": "v5.27.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "db6ec2ee967b7f06412c3a0cf1daaf072f4752a4" + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/db6ec2ee967b7f06412c3a0cf1daaf072f4752a4", - "reference": "db6ec2ee967b7f06412c3a0cf1daaf072f4752a4", + "url": "https://api.github.com/repos/laravel/socialite/zipball/40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", + "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", "shasum": "" }, "require": { @@ -2212,7 +2212,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-03-29T14:50:53+00:00" + "time": "2026-04-24T14:05:47+00:00" }, { "name": "laravel/tinker", @@ -3362,16 +3362,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.3", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "shasum": "" }, "require": { @@ -3463,7 +3463,7 @@ "type": "tidelift" } ], - "time": "2026-03-11T17:23:39+00:00" + "time": "2026-04-07T09:57:54+00:00" }, { "name": "nette/schema", @@ -4028,16 +4028,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.50", + "version": "3.0.52", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", "shasum": "" }, "require": { @@ -4118,7 +4118,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" }, "funding": [ { @@ -4134,7 +4134,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T02:57:58+00:00" + "time": "2026-04-27T07:02:15+00:00" }, { "name": "pragmarx/google2fa", @@ -6499,16 +6499,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -6558,7 +6558,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -6578,20 +6578,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -6640,7 +6640,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -6660,11 +6660,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -6727,7 +6727,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" }, "funding": [ { @@ -6751,7 +6751,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -6812,7 +6812,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -6836,16 +6836,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -6897,7 +6897,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -6917,20 +6917,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -6981,7 +6981,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -7001,20 +7001,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -7061,7 +7061,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -7081,20 +7081,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -7141,7 +7141,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" }, "funding": [ { @@ -7161,20 +7161,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -7221,7 +7221,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -7241,20 +7241,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -7304,7 +7304,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -7324,7 +7324,7 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", @@ -8285,23 +8285,23 @@ }, { "name": "voku/portable-ascii", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" }, "suggest": { "ext-intl": "Use Intl for transliterator_transliterate() support" @@ -8331,7 +8331,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { @@ -8355,7 +8355,7 @@ "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-04-26T05:33:54+00:00" }, { "name": "xemlock/htmlpurifier-html5", @@ -8723,16 +8723,16 @@ }, { "name": "larastan/larastan", - "version": "v3.9.3", + "version": "v3.9.6", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65" + "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65", - "reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65", + "url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", + "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", "shasum": "" }, "require": { @@ -8746,7 +8746,7 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", "php": "^8.2", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.1.44" }, "require-dev": { "doctrine/coding-standard": "^13", @@ -8801,7 +8801,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.9.3" + "source": "https://github.com/larastan/larastan/tree/v3.9.6" }, "funding": [ { @@ -8809,7 +8809,7 @@ "type": "github" } ], - "time": "2026-02-20T12:07:12+00:00" + "time": "2026-04-16T10:02:43+00:00" }, { "name": "mockery/mockery", @@ -8956,23 +8956,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.2", + "version": "v8.9.4", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "6eb16883e74fd725ac64dbe81544c961ab448ba5" + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/6eb16883e74fd725ac64dbe81544c961ab448ba5", - "reference": "6eb16883e74fd725ac64dbe81544c961ab448ba5", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.8 || ^8.0.4" + "symfony/console": "^7.4.8 || ^8.0.8" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -8980,12 +8980,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.3", - "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.2.0", - "laravel/pint": "^1.29.0", - "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.0.0", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.0.0" + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", "extra": { @@ -9048,7 +9048,7 @@ "type": "patreon" } ], - "time": "2026-03-31T21:51:27+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { "name": "phar-io/manifest", @@ -9170,11 +9170,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.46", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", - "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -9219,7 +9219,7 @@ "type": "github" } ], - "time": "2026-04-01T09:25:14+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10983,5 +10983,5 @@ "platform-overrides": { "php": "8.2.0" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From fddeb9030b6c65ea848d685de51f61f90ff4e21a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Apr 2026 18:31:11 +0100 Subject: [PATCH 43/69] Attachments: Added page access check to attachment delete Thanks to github.com/404-pkj for reporting. --- app/Uploads/Controllers/AttachmentController.php | 10 ++++++++-- tests/Uploads/AttachmentTest.php | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index 9c60fa415..aeee1f528 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -195,6 +195,7 @@ class AttachmentController extends Controller $this->validate($request, [ 'order' => ['required', 'array'], ]); + $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageUpdate, $page); @@ -221,8 +222,6 @@ class AttachmentController extends Controller throw new NotFoundException(trans('errors.attachment_not_found')); } - $this->checkOwnablePermission(Permission::PageView, $page); - if ($attachment->external) { return redirect($attachment->path); } @@ -247,6 +246,13 @@ class AttachmentController extends Controller { /** @var Attachment $attachment */ $attachment = Attachment::query()->findOrFail($attachmentId); + + try { + $this->pageQueries->findVisibleByIdOrFail($attachment->uploaded_to); + } catch (NotFoundException $exception) { + throw new NotFoundException(trans('errors.attachment_not_found')); + } + $this->checkOwnablePermission(Permission::AttachmentDelete, $attachment); $this->attachmentService->deleteFile($attachment); diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 945fc258d..2d402c340 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -5,6 +5,7 @@ namespace Tests\Uploads; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Tools\TrashCan; +use BookStack\Permissions\Permission; use BookStack\Uploads\Attachment; use Tests\TestCase; @@ -206,6 +207,21 @@ class AttachmentTest extends TestCase $this->files->deleteAllAttachmentFiles(); } + public function test_attachment_deletion_requires_page_access() + { + $page = $this->entities->page(); + $attachment = Attachment::factory()->create(['uploaded_to' => $page->id]); + $editor = $this->users->editor(); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->grantUserRolePermissions($editor, [Permission::AttachmentDeleteAll]); + + $resp = $this->actingAs($editor)->delete($attachment->getUrl()); + $resp->assertNotFound(); + + $this->assertDatabaseHas('attachments', ['id' => $attachment->id]); + } + public function test_attachment_access_without_permission_shows_404() { $admin = $this->users->admin(); From 3ddfa9b94838a19840bee477e8cb017ff9804b34 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 30 Apr 2026 00:32:27 +0100 Subject: [PATCH 44/69] Meta: Updated security info and fixed some tests/links --- .forgejo/SECURITY.md | 10 ++++------ readme.md | 2 +- tests/DebugViewTest.php | 2 +- version | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.forgejo/SECURITY.md b/.forgejo/SECURITY.md index f426079e8..5c044c946 100644 --- a/.forgejo/SECURITY.md +++ b/.forgejo/SECURITY.md @@ -12,16 +12,14 @@ If you'd like to be notified of new potential security concerns you can [sign-up ## Reporting a Vulnerability -If you've found an issue that likely has no impact to existing users (For example, in a development-only branch) -feel free to raise it via a standard GitHub bug report issue. +If you've found an issue that likely has no impact to existing users (For example, an issue only in the development branch) +feel free to raise it via a standard Codeberg bug report issue. If the issue could have a security impact to BookStack instances, -please directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown). -You will need to log in to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown). -Alternatively you can send a DM via Mastodon to [@danb@fosstodon.org](https://fosstodon.org/@danb). +please directly contact the lead maintainer via email Dan Brown using the [details found here](https://www.bookstackapp.com/links/contact/). Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability can often take a little time due to the amount of preparation required, to ensure the vulnerability has been covered, and to create the content required to adequately notify the user-base. -Thank you for keeping BookStack instances safe! +Thank you for keeping BookStack instances safe! \ No newline at end of file diff --git a/readme.md b/readme.md index 8b81abaee..30d4d1789 100644 --- a/readme.md +++ b/readme.md @@ -132,7 +132,7 @@ Security information for administering a BookStack instance can be found on the If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates). -If you would like to report a security concern, details of doing so [can be found here](/.forgejo/SECURITY.md). +If you would like to report a security concern, details of doing so [can be found here](.forgejo/SECURITY.md). ## ♿ Accessibility diff --git a/tests/DebugViewTest.php b/tests/DebugViewTest.php index 34de6b802..b9a99ec72 100644 --- a/tests/DebugViewTest.php +++ b/tests/DebugViewTest.php @@ -27,7 +27,7 @@ class DebugViewTest extends TestCase $resp->assertSeeText('BookStack Version: ' . trim(file_get_contents(base_path('version')))); // Dynamic help links $this->withHtml($resp)->assertElementExists('a[href*="q=' . urlencode('BookStack An error occurred during testing') . '"]'); - $this->withHtml($resp)->assertElementExists('a[href*="?q=is%3Aissue+' . urlencode('An error occurred during testing') . '"]'); + $this->withHtml($resp)->assertElementExists('a[href*="?q=' . urlencode('An error occurred during testing') . '"]'); } public function test_debug_view_only_shows_when_debug_mode_is_enabled() diff --git a/version b/version index 14f310dc3..9eadc470b 100644 --- a/version +++ b/version @@ -1 +1 @@ -v26.01-dev +v26.05-dev From cf648906e94a22f802898ba38236edd09a3909e4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 30 Apr 2026 09:31:56 +0100 Subject: [PATCH 45/69] SSR: Hardened URL validator against a range of workarounds Added a more comprehensive range of tests to cover. Thanks to naruhodoowl (https://github.com/kilhsrito-crypto) for reporting. --- app/Util/SsrUrlValidator.php | 31 ++++++- tests/Unit/SsrUrlValidatorTest.php | 62 ------------- tests/Util/SsrUrlValidatorTest.php | 142 +++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 66 deletions(-) delete mode 100644 tests/Unit/SsrUrlValidatorTest.php create mode 100644 tests/Util/SsrUrlValidatorTest.php diff --git a/app/Util/SsrUrlValidator.php b/app/Util/SsrUrlValidator.php index 076a653fc..a9b5c138d 100644 --- a/app/Util/SsrUrlValidator.php +++ b/app/Util/SsrUrlValidator.php @@ -8,6 +8,10 @@ use BookStack\Exceptions\HttpFetchException; * Validate the host we're connecting to when making a server-side-request. * Will use the given hosts config if given during construction otherwise * will look to the app configured config. + * + * The config format is a space-seperated list of URL prefixes which should contain the + * protocol and host. It can optionally define a path prefix as part of the URL. + * Wildcards, via a '*', can be used within these elements to match anything but a '/'. */ class SsrUrlValidator { @@ -48,15 +52,34 @@ class SsrUrlValidator { $pattern = rtrim(trim($pattern), '/'); $url = trim($url); + $urlParts = parse_url($url); - if (empty($pattern) || empty($url)) { + if (empty($pattern) || empty($url) || $urlParts === false) { return false; } - $quoted = preg_quote($pattern, '/'); - $regexPattern = str_replace('\*', '.*', $quoted); + // Prevent potential tricks using percent encoded slashes + if (str_contains(strtolower($urlParts['host'] ?? ''), '%2f')) { + return false; + } - return preg_match('/^' . $regexPattern . '($|\/.*$|#.*$)/i', $url); + // Disregard query and fragment + $url = explode('?', $url, 2)[0]; + $url = explode('#', $url, 2)[0]; + + // Disregard userinfo if existing + if (!empty($urlParts['user']) || !empty($urlParts['pass'])) { + [$start, $postUserinfo] = explode('@', $url, 2); + $preUserinfo = explode('//', $start, 2)[0]; + $url = ($preUserinfo ? $preUserinfo . '//' : '') . $postUserinfo; + } + + // Prepare pattern + $quoted = preg_quote($pattern, '/'); + $regexPattern = str_replace('\*', '[^\/]*', $quoted); + + // Check against our URL + return preg_match('/^' . $regexPattern . '($|\/.*$)/i', $url); } /** diff --git a/tests/Unit/SsrUrlValidatorTest.php b/tests/Unit/SsrUrlValidatorTest.php deleted file mode 100644 index 8fb538916..000000000 --- a/tests/Unit/SsrUrlValidatorTest.php +++ /dev/null @@ -1,62 +0,0 @@ - '', 'url' => '', 'result' => false], - ['config' => '', 'url' => 'https://example.com', 'result' => false], - ['config' => ' ', 'url' => 'https://example.com', 'result' => false], - ['config' => '*', 'url' => '', 'result' => false], - ['config' => '*', 'url' => 'https://example.com', 'result' => true], - ['config' => 'https://*', 'url' => 'https://example.com', 'result' => true], - ['config' => 'http://*', 'url' => 'https://example.com', 'result' => false], - ['config' => 'https://*example.com', 'url' => 'https://example.com', 'result' => true], - ['config' => 'https://*ample.com', 'url' => 'https://example.com', 'result' => true], - ['config' => 'https://*.example.com', 'url' => 'https://example.com', 'result' => false], - ['config' => 'https://*.example.com', 'url' => 'https://test.example.com', 'result' => true], - ['config' => '*//example.com', 'url' => 'https://example.com', 'result' => true], - ['config' => '*//example.com', 'url' => 'http://example.com', 'result' => true], - ['config' => '*//example.co', 'url' => 'http://example.co.uk', 'result' => false], - ['config' => '*//example.co/bookstack', 'url' => 'https://example.co/bookstack/a/path', 'result' => true], - ['config' => '*//example.co*', 'url' => 'https://example.co.uk/bookstack/a/path', 'result' => true], - ['config' => 'https://example.com', 'url' => 'https://example.com/a/b/c?test=cat', 'result' => true], - ['config' => 'https://example.com', 'url' => 'https://example.co.uk', 'result' => false], - - // Escapes - ['config' => 'https://(.*?).com', 'url' => 'https://example.com', 'result' => false], - ['config' => 'https://example.com', 'url' => 'https://example.co.uk#https://example.com', 'result' => false], - - // Multi values - ['config' => '*//example.org *//example.com', 'url' => 'https://example.com', 'result' => true], - ['config' => '*//example.org *//example.com', 'url' => 'https://example.com/a/b/c?test=cat#hello', 'result' => true], - ['config' => '*.example.org *.example.com', 'url' => 'https://example.co.uk', 'result' => false], - ['config' => ' *.example.org *.example.com ', 'url' => 'https://example.co.uk', 'result' => false], - ['config' => '* *.example.com', 'url' => 'https://example.co.uk', 'result' => true], - ['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.co.uk', 'result' => true], - ['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.net', 'result' => false], - ]; - - foreach ($testMap as $test) { - $result = (new SsrUrlValidator($test['config']))->allowed($test['url']); - $this->assertEquals($test['result'], $result, "Failed asserting url '{$test['url']}' with config '{$test['config']}' results " . ($test['result'] ? 'true' : 'false')); - } - } - - public function test_enssure_allowed() - { - $result = (new SsrUrlValidator('https://example.com'))->ensureAllowed('https://example.com'); - $this->assertNull($result); - - $this->expectException(HttpFetchException::class); - (new SsrUrlValidator('https://example.com'))->ensureAllowed('https://test.example.com'); - } -} diff --git a/tests/Util/SsrUrlValidatorTest.php b/tests/Util/SsrUrlValidatorTest.php new file mode 100644 index 000000000..12e176517 --- /dev/null +++ b/tests/Util/SsrUrlValidatorTest.php @@ -0,0 +1,142 @@ +set([ + 'app.ssr_hosts' => 'https://donkey.example.com', + ]); + + $validator = new SsrUrlValidator(); + + $this->assertTrue($validator->allowed('https://donkey.example.com')); + $this->assertFalse($validator->allowed('https://monkey.example.com')); + } + + public function test_config_string_can_be_passed_in_constructor() + { + config()->set([ + 'app.ssr_hosts' => 'https://donkey.example.com', + ]); + + $validator = new SsrUrlValidator('https://monkey.example.com'); + + $this->assertFalse($validator->allowed('https://donkey.example.com')); + $this->assertTrue($validator->allowed('https://monkey.example.com')); + } + + public function test_config_string_can_include_multiple_space_seperated_values() + { + $validator = new SsrUrlValidator('https://monkey.example.com https://cat.example.com'); + + $this->assertFalse($validator->allowed('https://donkey.example.com')); + $this->assertTrue($validator->allowed('https://monkey.example.com')); + $this->assertTrue($validator->allowed('https://cat.example.com')); + } + + public function test_ensure_allowed_throws_if_not_allowed() + { + $validator = new SsrUrlValidator('https://monkey.example.com'); + + $this->assertNull($validator->ensureAllowed('https://monkey.example.com')); + + $this->assertThrows(function () use ($validator) { + $validator->ensureAllowed('https://donkey.example.com'); + }, HttpFetchException::class, 'The URL does not match the configured allowed SSR hosts'); + } + + public function test_basic_url_matching() + { + $tests = [ + // Single values + ['config' => '', 'url' => '', 'result' => false], + ['config' => '', 'url' => 'https://example.com', 'result' => false], + ['config' => ' ', 'url' => 'https://example.com', 'result' => false], + ['config' => '*', 'url' => '', 'result' => false], + ['config' => '*', 'url' => 'https://example.com', 'result' => true], + ['config' => 'https://*', 'url' => 'https://example.com', 'result' => true], + ['config' => 'http://*', 'url' => 'https://example.com', 'result' => false], + ['config' => 'https://*example.com', 'url' => 'https://example.com', 'result' => true], + ['config' => 'https://*ample.com', 'url' => 'https://example.com', 'result' => true], + ['config' => 'https://*.example.com', 'url' => 'https://example.com', 'result' => false], + ['config' => 'https://*.example.com', 'url' => 'https://test.example.com', 'result' => true], + ['config' => '*//example.com', 'url' => 'https://example.com', 'result' => true], + ['config' => '*//example.com', 'url' => 'http://example.com', 'result' => true], + ['config' => '*//example.co', 'url' => 'http://example.co.uk', 'result' => false], + ['config' => '*//example.co/bookstack', 'url' => 'https://example.co/bookstack/a/path', 'result' => true], + ['config' => '*//example.co*', 'url' => 'https://example.co.uk/bookstack/a/path', 'result' => true], + ['config' => 'https://example.com', 'url' => 'https://example.com/a/b/c?test=cat', 'result' => true], + ['config' => 'https://example.com', 'url' => 'https://example.co.uk', 'result' => false], + + // Escapes + ['config' => 'https://(.*?).com', 'url' => 'https://example.com', 'result' => false], + ['config' => 'https://example.com', 'url' => 'https://example.co.uk#https://example.com', 'result' => false], + + // Multi values + ['config' => '*//example.org *//example.com', 'url' => 'https://example.com', 'result' => true], + ['config' => '*//example.org *//example.com', 'url' => 'https://example.com/a/b/c?test=cat#hello', 'result' => true], + ['config' => '*.example.org *.example.com', 'url' => 'https://example.co.uk', 'result' => false], + ['config' => ' *.example.org *.example.com ', 'url' => 'https://example.co.uk', 'result' => false], + ['config' => '* *.example.com', 'url' => 'https://example.co.uk', 'result' => true], + ['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.co.uk', 'result' => true], + ['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.net', 'result' => false], + + // Further tests + ['config' => 'https://monkey.example.com', 'url' => 'https://monkey.example.com/a/b', 'result' => true,], + ['config' => 'https://monkey.example.com', 'url' => 'https://monkey.example.com/a/b?a=b#ab', 'result' => true,], + ['config' => 'https://monkey.example.com', 'url' => 'https://monkey.example.com:8080/a', 'result' => false,], + ['config' => '*', 'url' => 'https://a.example.com', 'result' => true,], + ['config' => 'https://monkey.example.com', 'url' => 'http://monkey.example.com/a/b?a=b#ab', 'result' => false,], + ['config' => 'https://monkey.example.com', 'url' => 'https://beans.monkey.example.com/a/b?a=b#ab', 'result' => false,], + ['config' => 'https://*monkey.example.com', 'url' => 'https://amonkey.example.com/a/b?a=b#ab', 'result' => true,], + ['config' => 'https://*monkey.example.com', 'url' => 'https://donkey.example.com/a/b/monkey.example.com/b?a=b#ab', 'result' => false,], + ['config' => 'https://monkey.example.com', 'url' => 'https://example.com/monkey.example.com/b?a=monkey.example.com#monkey.example.com', 'result' => false,], + ['config' => 'https://*.example.com', 'url' => 'https://a.b.example.com/a/b', 'result' => true,], + ['config' => 'https://*.example.com', 'url' => 'https://a.b.example.a.com/a/b', 'result' => false,], + ['config' => 'https://*.example.com', 'url' => 'https://a.com/a/b?val=a.example.com', 'result' => false,], + ['config' => 'https://*.example.com', 'url' => 'https://a.com/a/b#example.com', 'result' => false,], + ['config' => 'https://a.*.example.com', 'url' => 'https://a.b.c.example.com/c/d', 'result' => true,], + ['config' => 'https://example.com/webhooks/', 'url' => 'https://example.com/webhooks/beans', 'result' => true,], + ['config' => 'https://example.com/webhooks/', 'url' => 'https://example.com/a/webhooks/', 'result' => false,], + ['config' => 'https://example.com:8080', 'url' => 'https://example.com/a/b', 'result' => false,], + ['config' => 'https://example.com:8080', 'url' => 'https://example.com:8080/a/b', 'result' => true,], + ['config' => 'https://example.com/*', 'url' => 'https://example.com:8080/a/b', 'result' => false,], + ]; + + foreach ($tests as $testCase) { + $validator = new SsrUrlValidator($testCase['config']); + $result = $validator->allowed($testCase['url']); + $this->assertEquals($testCase['result'], $result, "Failed asserting expected result for config {$testCase['config']} and test value {$testCase['url']}"); + } + } + + public function test_wildcard_does_not_match_userinfo_data_but_still_allows_it() + { + $validator = new SsrUrlValidator('https://*monkey.example.com'); + $this->assertFalse($validator->allowed('https://monkey.example.com@a.example.com')); + + $validator = new SsrUrlValidator('https://monkey.example.com*'); + $this->assertFalse($validator->allowed('https://monkey.example.com@a.example.com')); + $this->assertFalse($validator->allowed('https://monkey.example.com:monkey.example.com@a.example.com')); + + $validator = new SsrUrlValidator('https://monkey.example.com'); + $this->assertTrue($validator->allowed('https://a:b@monkey.example.com')); + } + + public function test_percent_encoded_slashes_in_host_are_rejected() + { + $validator = new SsrUrlValidator('*'); + + $this->assertFalse($validator->allowed('https://cat.example.com%2Fa/b')); + $this->assertFalse($validator->allowed('https://cat.example.com%2fa/b')); + $this->assertFalse($validator->allowed('https://cat%2f.example.com/a/b')); + $this->assertFalse($validator->allowed('https://cat.exa%2Fmple.com')); + } +} From ccbeefe674b1db6a7c882f3868f66108a05f7d01 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 5 May 2026 04:30:37 +0000 Subject: [PATCH 46/69] New Crowdin translations by GitHub Action --- lang/ar/entities.php | 1 + lang/ar/settings.php | 1 + lang/bg/entities.php | 1 + lang/bg/settings.php | 1 + lang/bn/entities.php | 1 + lang/bn/settings.php | 1 + lang/bs/entities.php | 1 + lang/bs/settings.php | 1 + lang/ca/entities.php | 1 + lang/ca/settings.php | 1 + lang/cs/entities.php | 1 + lang/cs/settings.php | 1 + lang/cy/entities.php | 1 + lang/cy/settings.php | 1 + lang/da/entities.php | 1 + lang/da/settings.php | 1 + lang/de/entities.php | 1 + lang/de/settings.php | 1 + lang/de/validation.php | 2 +- lang/de_informal/entities.php | 5 +- lang/de_informal/notifications.php | 4 +- lang/de_informal/settings.php | 3 +- lang/de_informal/validation.php | 2 +- lang/el/entities.php | 1 + lang/el/settings.php | 1 + lang/es/entities.php | 1 + lang/es/settings.php | 1 + lang/es_AR/entities.php | 1 + lang/es_AR/settings.php | 1 + lang/et/entities.php | 1 + lang/et/settings.php | 1 + lang/eu/entities.php | 1 + lang/eu/settings.php | 1 + lang/fa/entities.php | 1 + lang/fa/settings.php | 1 + lang/fi/entities.php | 1 + lang/fi/settings.php | 1 + lang/fr/auth.php | 4 +- lang/fr/entities.php | 1 + lang/fr/settings.php | 1 + lang/he/entities.php | 1 + lang/he/settings.php | 1 + lang/hr/entities.php | 1 + lang/hr/settings.php | 1 + lang/hu/entities.php | 1 + lang/hu/settings.php | 1 + lang/id/entities.php | 1 + lang/id/settings.php | 1 + lang/is/entities.php | 1 + lang/is/settings.php | 1 + lang/it/entities.php | 1 + lang/it/settings.php | 1 + lang/ja/entities.php | 1 + lang/ja/settings.php | 1 + lang/ka/entities.php | 1 + lang/ka/settings.php | 1 + lang/ko/entities.php | 1 + lang/ko/settings.php | 1 + lang/ku/entities.php | 1 + lang/ku/settings.php | 1 + lang/lt/entities.php | 1 + lang/lt/settings.php | 1 + lang/lv/entities.php | 1 + lang/lv/settings.php | 1 + lang/nb/entities.php | 1 + lang/nb/settings.php | 1 + lang/ne/entities.php | 1 + lang/ne/settings.php | 1 + lang/nl/entities.php | 1 + lang/nl/settings.php | 1 + lang/nn/entities.php | 1 + lang/nn/settings.php | 1 + lang/pl/entities.php | 1 + lang/pl/settings.php | 1 + lang/pt/entities.php | 1 + lang/pt/settings.php | 1 + lang/pt_BR/auth.php | 2 +- lang/pt_BR/entities.php | 1 + lang/pt_BR/settings.php | 1 + lang/ro/entities.php | 1 + lang/ro/settings.php | 1 + lang/ru/activities.php | 10 +- lang/ru/common.php | 4 +- lang/ru/editor.php | 6 +- lang/ru/entities.php | 23 +- lang/ru/notifications.php | 2 +- lang/ru/preferences.php | 2 +- lang/ru/settings.php | 25 +- lang/ru/validation.php | 10 +- lang/sk/entities.php | 1 + lang/sk/settings.php | 1 + lang/sl/entities.php | 1 + lang/sl/settings.php | 1 + lang/sq/entities.php | 1 + lang/sq/settings.php | 1 + lang/sr/entities.php | 1 + lang/sr/settings.php | 1 + lang/sv/entities.php | 1 + lang/sv/settings.php | 1 + lang/th/activities.php | 140 +++++++++ lang/th/auth.php | 117 +++++++ lang/th/common.php | 115 +++++++ lang/th/components.php | 46 +++ lang/th/editor.php | 182 +++++++++++ lang/th/entities.php | 477 +++++++++++++++++++++++++++++ lang/th/errors.php | 135 ++++++++ lang/th/notifications.php | 29 ++ lang/th/pagination.php | 12 + lang/th/passwords.php | 15 + lang/th/preferences.php | 52 ++++ lang/th/settings.php | 375 +++++++++++++++++++++++ lang/th/validation.php | 123 ++++++++ lang/tk/entities.php | 1 + lang/tk/settings.php | 1 + lang/tr/entities.php | 1 + lang/tr/settings.php | 1 + lang/uk/entities.php | 1 + lang/uk/settings.php | 1 + lang/uz/entities.php | 1 + lang/uz/settings.php | 1 + lang/vi/entities.php | 1 + lang/vi/settings.php | 1 + lang/zh_CN/entities.php | 1 + lang/zh_CN/settings.php | 1 + lang/zh_TW/entities.php | 1 + lang/zh_TW/settings.php | 1 + 126 files changed, 1970 insertions(+), 50 deletions(-) create mode 100644 lang/th/activities.php create mode 100644 lang/th/auth.php create mode 100644 lang/th/common.php create mode 100644 lang/th/components.php create mode 100644 lang/th/editor.php create mode 100644 lang/th/entities.php create mode 100644 lang/th/errors.php create mode 100644 lang/th/notifications.php create mode 100644 lang/th/pagination.php create mode 100644 lang/th/passwords.php create mode 100644 lang/th/preferences.php create mode 100644 lang/th/settings.php create mode 100644 lang/th/validation.php diff --git a/lang/ar/entities.php b/lang/ar/entities.php index 8b2882c60..64192a021 100644 --- a/lang/ar/entities.php +++ b/lang/ar/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'نقل الفصول والصفحات داخل الكتاب لإعادة تنظيم محتوياته. يمكن إضافة كتب أخرى مما يسمح بنقل الفصول والصفحات بسهولة بين الكتب. اختياريًا، يمكن تعيين قاعدة فرز تلقائي لفرز محتويات هذا الكتاب تلقائيًا عند حدوث تغييرات.', 'books_sort_auto_sort' => 'خِيار الفرز التلقائي', 'books_sort_auto_sort_active' => 'الفرز التلقائي الشَغَّال: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'فرز كتاب :bookName', 'books_sort_name' => 'ترتيب حسب الإسم', 'books_sort_created' => 'ترتيب حسب تاريخ الإنشاء', diff --git a/lang/ar/settings.php b/lang/ar/settings.php index 3191bbe3a..a1fef8364 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'الكل', 'role_own' => 'ما يخص', 'role_controlled_by_asset' => 'يتحكم فيها الأصول التي يتم رفعها إلى', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'حفظ الدور', 'role_users' => 'مستخدمون داخل هذا الدور', 'role_users_none' => 'لم يتم تعيين أي مستخدمين لهذا الدور', diff --git a/lang/bg/entities.php b/lang/bg/entities.php index 42cdad801..19e4f5188 100644 --- a/lang/bg/entities.php +++ b/lang/bg/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Сортирай книга :bookName', 'books_sort_name' => 'Сортиране по име', 'books_sort_created' => 'Сортирай по дата на създаване', diff --git a/lang/bg/settings.php b/lang/bg/settings.php index a1297e446..0554100fd 100644 --- a/lang/bg/settings.php +++ b/lang/bg/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Всички', 'role_own' => 'Собствени', 'role_controlled_by_asset' => 'Контролирани от актива, към който са качени', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Запази ролята', 'role_users' => 'Потребители в тази роля', 'role_users_none' => 'В момента няма потребители, назначени за тази роля', diff --git a/lang/bn/entities.php b/lang/bn/entities.php index 74c50be3b..5501d2bc2 100644 --- a/lang/bn/entities.php +++ b/lang/bn/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', diff --git a/lang/bn/settings.php b/lang/bn/settings.php index 94ad059d4..1bc5d1551 100644 --- a/lang/bn/settings.php +++ b/lang/bn/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Save Role', 'role_users' => 'Users in this role', 'role_users_none' => 'No users are currently assigned to this role', diff --git a/lang/bs/entities.php b/lang/bs/entities.php index f671e1b45..b490c8c94 100644 --- a/lang/bs/entities.php +++ b/lang/bs/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sortiraj knjigu :bookName', 'books_sort_name' => 'Sortiraj po imenu', 'books_sort_created' => 'Sortiraj po datumu kreiranja', diff --git a/lang/bs/settings.php b/lang/bs/settings.php index c4d1eb136..3937c650f 100644 --- a/lang/bs/settings.php +++ b/lang/bs/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Save Role', 'role_users' => 'Users in this role', 'role_users_none' => 'No users are currently assigned to this role', diff --git a/lang/ca/entities.php b/lang/ca/entities.php index 108a38c2c..edbfdbc44 100644 --- a/lang/ca/entities.php +++ b/lang/ca/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Mou capítols i pàgines dins d\'un llibre per reorganitzar el seu contingut. Es poden afegir altres llibres que permetin moure fàcilment capítols i pàgines entre llibres. De manera opcional, es poden establir regles d\'ordenació automàtica per ordenar automàticament el contingut d\'aquest llibre quan hi hagi canvis.', 'books_sort_auto_sort' => 'Opció d\'ordenació automàtica', 'books_sort_auto_sort_active' => 'Opció d\'ordenació activa :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Ordena el llibre «:bookName»', 'books_sort_name' => 'Ordena pel nom', 'books_sort_created' => 'Ordena per la data de creació', diff --git a/lang/ca/settings.php b/lang/ca/settings.php index a890b9809..2a2106eb3 100644 --- a/lang/ca/settings.php +++ b/lang/ca/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Tot', 'role_own' => 'Propi', 'role_controlled_by_asset' => 'Controlat pel recurs a què estan pujats', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Desa el rol', 'role_users' => 'Usuaris assignats en aquest rol', 'role_users_none' => 'No hi ha cap usuari assignat en aquest rol', diff --git a/lang/cs/entities.php b/lang/cs/entities.php index d65d85ccb..7db62f7bb 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Pro přeuspořádání obsahu přesuňte kapitoly a stránky v knize. Mohou být přidány další knihy, které umožní snadný přesun kapitol a stránek mezi knihami. Volitelně lze nastavit pravidlo automatického řazení, aby se při změnách automaticky seřadil obsah této knihy.', 'books_sort_auto_sort' => 'Možnost automatického řazení', 'books_sort_auto_sort_active' => 'Aktivní automatické řazení: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Seřadit knihu :bookName', 'books_sort_name' => 'Seřadit podle názvu', 'books_sort_created' => 'Seřadit podle data vytvoření', diff --git a/lang/cs/settings.php b/lang/cs/settings.php index a8c4036e8..a7ab9927d 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Vše', 'role_own' => 'Vlastní', 'role_controlled_by_asset' => 'Řídí se obsahem, do kterého jsou nahrávány', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Uložit roli', 'role_users' => 'Uživatelé mající tuto roli', 'role_users_none' => 'Žádný uživatel nemá tuto roli', diff --git a/lang/cy/entities.php b/lang/cy/entities.php index e6df4317b..af1f6c43c 100644 --- a/lang/cy/entities.php +++ b/lang/cy/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Trefnu Llyfr :bookName', 'books_sort_name' => 'Trefnu yn ôl Enw', 'books_sort_created' => 'Trefnu yn ôl Dyddiad Creu', diff --git a/lang/cy/settings.php b/lang/cy/settings.php index f4fbf0bba..a0519cccf 100644 --- a/lang/cy/settings.php +++ b/lang/cy/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Popeth', 'role_own' => 'Meddu', 'role_controlled_by_asset' => 'Wedi\'u rheoli gan yr ased y maent yn cael eu huwchlwytho iddo', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Cadw Rôl', 'role_users' => 'Defnyddwyr yn y rôl hon', 'role_users_none' => 'Nid oes unrhyw ddefnyddwyr wedi’u neilltuo i\'r rôl hon ar hyn o bryd', diff --git a/lang/da/entities.php b/lang/da/entities.php index ecda8a8cf..0aa35cc5d 100644 --- a/lang/da/entities.php +++ b/lang/da/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Flyt kapitler og sider i en bog for at omorganisere dens indhold. Der kan tilføjes andre bøger, som gør det nemt at flytte kapitler og sider mellem bøgerne. Man kan indstille en automatisk sorteringsregel, så bogens indhold automatisk sorteres efter ændringer.', 'books_sort_auto_sort' => 'Mulighed for automatisk sortering', 'books_sort_auto_sort_active' => 'Automatisk sortering Aktiv: :sortName', + 'books_sort_auto_sort_creation_hint' => 'En bruger med de nødvendige rettigheder kan oprette regler for automatisk sortering i indstillingsområdet »Lister og sortering«.', 'books_sort_named' => 'Sorter bog :bookName', 'books_sort_name' => 'Sortér efter navn', 'books_sort_created' => 'Sortér efter oprettelsesdato', diff --git a/lang/da/settings.php b/lang/da/settings.php index 1edf10d0e..fb5a1c958 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Alle', 'role_own' => 'Eget', 'role_controlled_by_asset' => 'Styres af det medie/"asset", de uploades til', + 'role_controlled_by_page_delete' => 'Styres af tilladelser til sletning af sider', 'role_save' => 'Gem rolle', 'role_users' => 'Brugere med denne rolle', 'role_users_none' => 'Ingen brugere er i øjeblikket tildelt denne rolle', diff --git a/lang/de/entities.php b/lang/de/entities.php index 52f9b7aca..db5d070f4 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Verschieben Sie Kapitel und Seiten innerhalb eines Buches, um dessen Inhalt neu zu ordnen. Es können weitere Bücher hinzugefügt werden, wodurch Kapitel und Seiten problemlos zwischen den Büchern verschoben werden können. Optional kann eine automatische Sortierregel festgelegt werden, um den Inhalt dieses Buches bei Änderungen automatisch zu sortieren.', 'books_sort_auto_sort' => 'Automatische Sortierfunktionsoption', 'books_sort_auto_sort_active' => 'Automatische Sortierung aktiv: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Buch ":bookName" sortieren', 'books_sort_name' => 'Sortieren nach Namen', 'books_sort_created' => 'Sortieren nach Erstellungsdatum', diff --git a/lang/de/settings.php b/lang/de/settings.php index 64af973a4..691ee2ee6 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Alle', 'role_own' => 'Eigene', 'role_controlled_by_asset' => 'Abhängig von dem Asset, in das sie hochgeladen werden', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Rolle speichern', 'role_users' => 'Dieser Rolle zugeordnete Benutzer', 'role_users_none' => 'Derzeit sind diesem Rollentyp keine Benutzer zugewiesen', diff --git a/lang/de/validation.php b/lang/de/validation.php index 21e850bbf..40f6e76a1 100644 --- a/lang/de/validation.php +++ b/lang/de/validation.php @@ -106,7 +106,7 @@ return [ 'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.', 'zip_file' => ':attribute muss eine Datei innerhalb des ZIP referenzieren.', - 'zip_file_size' => 'The file :attribute must not exceed :size MB.', + 'zip_file_size' => 'Die Datei :attribute darf :size MB nicht überschreiten.', 'zip_file_mime' => ':attribute muss eine Datei des Typs :validType referenzieren, gefunden :foundType.', 'zip_model_expected' => 'Datenobjekt erwartet, aber ":type" gefunden.', 'zip_unique' => ':attribute muss für den Objekttyp innerhalb des ZIP eindeutig sein.', diff --git a/lang/de_informal/entities.php b/lang/de_informal/entities.php index 16502051c..708397e7e 100644 --- a/lang/de_informal/entities.php +++ b/lang/de_informal/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Kapitel und Seiten innerhalb eines Buches verschieben, um dessen Inhalt zu reorganisieren. Andere Bücher können hinzugefügt werden, was das Verschieben von Kapiteln und Seiten zwischen Büchern erleichtert. Optional kann eine automatische Sortierregel erstellt werden, um den Inhalt dieses Buches nach Änderungen automatisch zu sortieren.', 'books_sort_auto_sort' => 'Auto-Sortieroption', 'books_sort_auto_sort_active' => 'Automatische Sortierung aktiv: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Buch ":bookName" sortieren', 'books_sort_name' => 'Sortieren nach Namen', 'books_sort_created' => 'Sortieren nach Erstellungsdatum', @@ -410,10 +411,10 @@ return [ 'comment_deleted_success' => 'Kommentar gelöscht', 'comment_created_success' => 'Kommentar hinzugefügt', 'comment_updated_success' => 'Kommentar aktualisiert', - 'comment_archive_success' => 'Kommentar archiviert', + 'comment_archive_success' => 'Kommentar wurde archiviert', 'comment_unarchive_success' => 'Kommentar nicht mehr archiviert', 'comment_view' => 'Kommentar ansehen', - 'comment_jump_to_thread' => 'Zum Thema springen', + 'comment_jump_to_thread' => 'Zu diesem Thema springen', 'comment_delete_confirm' => 'Möchtst du diesen Kommentar wirklich löschen?', 'comment_in_reply_to' => 'Antwort auf :commentId', 'comment_reference' => 'Referenz', diff --git a/lang/de_informal/notifications.php b/lang/de_informal/notifications.php index 4df170783..dcbe16c93 100644 --- a/lang/de_informal/notifications.php +++ b/lang/de_informal/notifications.php @@ -11,8 +11,8 @@ return [ 'updated_page_subject' => 'Aktualisierte Seite: :pageName', 'updated_page_intro' => 'Eine Seite wurde in :appName aktualisiert:', 'updated_page_debounce' => 'Um eine Flut von Benachrichtigungen zu vermeiden, wirst du für eine gewisse Zeit keine Benachrichtigungen für weitere Bearbeitungen dieser Seite durch denselben Bearbeiter erhalten.', - 'comment_mention_subject' => 'Sie wurden in einem Kommentar auf der Seite :pageName erwähnt', - 'comment_mention_intro' => 'Sie wurden in einem Kommentar zu :appName: erwähnt', + 'comment_mention_subject' => 'Du wurdest in einem Kommentar auf der Seite :pageName erwähnt', + 'comment_mention_intro' => 'Du wurdest in einem Kommentar zu :appName erwähnt:', 'detail_page_name' => 'Seitenname:', 'detail_page_path' => 'Seitenpfad:', diff --git a/lang/de_informal/settings.php b/lang/de_informal/settings.php index 97ad607d6..93ed1902f 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -76,7 +76,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'reg_confirm_restrict_domain_placeholder' => 'Keine Einschränkung gesetzt', // Sorting Settings - 'sorting' => 'Listen & Sortieren', + 'sorting' => 'Listen und Sortierung', 'sorting_book_default' => 'Standardregel für die Sortierung von Büchern', 'sorting_book_default_desc' => 'Wähle die Standard-Sortierregel aus, die auf neue Bücher angewendet werden soll. Dies wirkt sich nicht auf bestehende Bücher aus und kann pro Buch überschrieben werden.', 'sorting_rules' => 'Sortierregeln', @@ -208,6 +208,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'role_all' => 'Alle', 'role_own' => 'Eigene', 'role_controlled_by_asset' => 'Berechtigungen werden vom Uploadziel bestimmt', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Rolle speichern', 'role_users' => 'Dieser Rolle zugeordnete Benutzer', 'role_users_none' => 'Bisher sind dieser Rolle keine Benutzer zugeordnet', diff --git a/lang/de_informal/validation.php b/lang/de_informal/validation.php index d693adecd..5dcbd5102 100644 --- a/lang/de_informal/validation.php +++ b/lang/de_informal/validation.php @@ -106,7 +106,7 @@ return [ 'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.', 'zip_file' => ':attribute muss auf eine Datei innerhalb des ZIP verweisen.', - 'zip_file_size' => 'The file :attribute must not exceed :size MB.', + 'zip_file_size' => 'Die Datei :attribute darf :size MB nicht überschreiten.', 'zip_file_mime' => ':attribute muss eine Datei des Typs :validType referenzieren, gefunden :foundType.', 'zip_model_expected' => 'Datenobjekt erwartet, aber ":type" gefunden.', 'zip_unique' => ':attribute muss für den Objekttyp innerhalb des ZIP eindeutig sein.', diff --git a/lang/el/entities.php b/lang/el/entities.php index 3779cd18a..b551d1a86 100644 --- a/lang/el/entities.php +++ b/lang/el/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Ταξινόμηση Βιβλίου :bookname', 'books_sort_name' => 'Ταξινόμηση κατά όνομα', 'books_sort_created' => 'Ταξινόμηση κατά ημερομηνία δημιουργίας', diff --git a/lang/el/settings.php b/lang/el/settings.php index 6ec5c4fdd..605b8b40e 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Ολα', 'role_own' => 'Τα δικά του', 'role_controlled_by_asset' => 'Ελέγχονται από το στοιχείο στο οποίο ανεβαίνουν (Ράφια, Βιβλία)', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Αποθήκευση Ρόλου', 'role_users' => 'Χρήστες σε αυτόν τον Ρόλο', 'role_users_none' => 'Σε κανένα χρήστη δεν έχει ανατεθεί αυτήν τη στιγμή αυτός ο ρόλος.', diff --git a/lang/es/entities.php b/lang/es/entities.php index e652d4dc0..a8ec3b10a 100644 --- a/lang/es/entities.php +++ b/lang/es/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Mueve capítulos y páginas dentro de un libro para reorganizar su contenido. Se pueden añadir otros libros que permiten mover fácilmente capítulos y páginas entre libros. Opcionalmente, se puede establecer una regla de ordenación automática para ordenar automáticamente el contenido de este libro cuando haya cambios.', 'books_sort_auto_sort' => 'Opción de ordenación automática', 'books_sort_auto_sort_active' => 'Opción de ordenación activa: sortName', + 'books_sort_auto_sort_creation_hint' => 'Las reglas para los ajustes de ordenación automática pueden ser creadas en el área de configuración "Listas y ordenación" por un usuario con los permisos pertinentes.', 'books_sort_named' => 'Organizar libro :bookName', 'books_sort_name' => 'Organizar por Nombre', 'books_sort_created' => 'Organizar por Fecha de creación', diff --git a/lang/es/settings.php b/lang/es/settings.php index bfd3ce1cf..516480b64 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Todo', 'role_own' => 'Propio', 'role_controlled_by_asset' => 'Controlado por el contenido al que ha sido subido', + 'role_controlled_by_page_delete' => 'Controlado por página de eliminación de permisos', 'role_save' => 'Guardar rol', 'role_users' => 'Usuarios en este rol', 'role_users_none' => 'No hay usuarios asignados a este rol', diff --git a/lang/es_AR/entities.php b/lang/es_AR/entities.php index 1ae092de1..2dc1bd648 100644 --- a/lang/es_AR/entities.php +++ b/lang/es_AR/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Mueve capítulos y páginas dentro de un libro para reorganizar su contenido. Se pueden añadir otros libros que permiten mover fácilmente capítulos y páginas entre libros. Opcionalmente, se puede establecer una regla de ordenación automática para el contenido de este libro cuando haya cambios.', 'books_sort_auto_sort' => 'Opción de ordenación automática', 'books_sort_auto_sort_active' => 'Opción de ordenación activa: sortName', + 'books_sort_auto_sort_creation_hint' => 'Las reglas para los ajustes de ordenación automática pueden ser creadas en el área de configuración "Listas y ordenación" por un usuario con los permisos pertinentes.', 'books_sort_named' => 'Organizar libro :bookName', 'books_sort_name' => 'Organizar por nombre', 'books_sort_created' => 'Organizar por fecha de creación', diff --git a/lang/es_AR/settings.php b/lang/es_AR/settings.php index 90f43a6f2..3b82d0fb3 100644 --- a/lang/es_AR/settings.php +++ b/lang/es_AR/settings.php @@ -208,6 +208,7 @@ return [ 'role_all' => 'Todo', 'role_own' => 'Propio', 'role_controlled_by_asset' => 'Controlado por el activo al que ha sido subido', + 'role_controlled_by_page_delete' => 'Controlado por página de eliminación de permisos', 'role_save' => 'Guardar rol', 'role_users' => 'Usuarios en este rol', 'role_users_none' => 'No hay usuarios asignados a este rol', diff --git a/lang/et/entities.php b/lang/et/entities.php index 323985b2d..4b7be605a 100644 --- a/lang/et/entities.php +++ b/lang/et/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Liiguta raamatu sees peatükke ja lehti, et selle sisu ümber organiseerida. Saad lisada teisi raamatuid, mis võimaldab peatükke ja lehti lihtsasti raamatute vahel liigutada. Lisaks saad määrata automaatse sorteerimise reegli, et selle raamatu sisu muudatuste puhul automaatselt järjestada.', 'books_sort_auto_sort' => 'Automaatne sorteerimine', 'books_sort_auto_sort_active' => 'Automaatne sorteerimine aktiivne: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sorteeri raamat :bookName', 'books_sort_name' => 'Sorteeri nime järgi', 'books_sort_created' => 'Sorteeri loomisaja järgi', diff --git a/lang/et/settings.php b/lang/et/settings.php index bc5a7794e..fd00da9e3 100644 --- a/lang/et/settings.php +++ b/lang/et/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Kõik', 'role_own' => 'Enda omad', 'role_controlled_by_asset' => 'Õigused määratud seotud objekti kaudu', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Salvesta roll', 'role_users' => 'Selle rolliga kasutajad', 'role_users_none' => 'Seda rolli ei ole hetkel ühelgi kasutajal', diff --git a/lang/eu/entities.php b/lang/eu/entities.php index cc51012d5..4d9e7cb06 100644 --- a/lang/eu/entities.php +++ b/lang/eu/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Ordenatu :bookName liburua', 'books_sort_name' => 'Ordenatu izenaren arabera', 'books_sort_created' => 'Ordenatu argitaratze-dataren arabera', diff --git a/lang/eu/settings.php b/lang/eu/settings.php index 0f764dccb..dd346d1c9 100644 --- a/lang/eu/settings.php +++ b/lang/eu/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Guztiak', 'role_own' => 'Norberarenak', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Gorde rol-a', 'role_users' => 'Rol honetako erabiltzaileak', 'role_users_none' => 'No users are currently assigned to this role', diff --git a/lang/fa/entities.php b/lang/fa/entities.php index d7c0cc1c0..2abcff576 100644 --- a/lang/fa/entities.php +++ b/lang/fa/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'برای سامان‌دهی محتوای یک کتاب، می‌توانید فصل‌ها و صفحات آن را جابه‌جا کنید. همچنین می‌توانید کتاب‌های دیگری بیفزایید تا جابه‌جایی فصل‌ها و صفحات میان کتاب‌ها آسان شود. در صورت تمایل، می‌توانید قاعده‌ای برای مرتب‌سازی خودکار تعیین کنید تا محتوای کتاب در صورت ایجاد تغییرات، به طور خودکار مرتب شود.', 'books_sort_auto_sort' => 'گزینه مرتب‌سازی خودکار', 'books_sort_auto_sort_active' => 'مرتب‌سازی خودکار با قاعده: :sortName فعال است', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'مرتب‌سازی کتاب:bookName', 'books_sort_name' => 'مرتب‌سازی بر اساس نام', 'books_sort_created' => 'مرتب‌سازی بر اساس تاریخ ایجاد', diff --git a/lang/fa/settings.php b/lang/fa/settings.php index 2fa115118..21ad96240 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'همه', 'role_own' => 'صاحب', 'role_controlled_by_asset' => 'توسط دارایی که در آن آپلود می شود کنترل می شود', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'ذخیره نقش', 'role_users' => 'کاربران در این نقش', 'role_users_none' => 'در حال حاضر هیچ کاربری به این نقش اختصاص داده نشده است', diff --git a/lang/fi/entities.php b/lang/fi/entities.php index c64621f0d..dd2ad5e69 100644 --- a/lang/fi/entities.php +++ b/lang/fi/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Järjestä kirja :bookName', 'books_sort_name' => 'Järjestä nimen mukaan', 'books_sort_created' => 'Järjestä luontipäiväyksen mukaan', diff --git a/lang/fi/settings.php b/lang/fi/settings.php index adc47fe2d..3aec01886 100644 --- a/lang/fi/settings.php +++ b/lang/fi/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Kaikki', 'role_own' => 'Omat', 'role_controlled_by_asset' => 'Määräytyy sen sisällön mukaan, johon ne on ladattu', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Tallenna rooli', 'role_users' => 'Käyttäjät tässä roolissa', 'role_users_none' => 'Yhtään käyttäjää ei ole osoitettuna tähän rooliin', diff --git a/lang/fr/auth.php b/lang/fr/auth.php index fab11a83b..61ca0ee3e 100644 --- a/lang/fr/auth.php +++ b/lang/fr/auth.php @@ -12,9 +12,9 @@ return [ // Login & Register 'sign_up' => 'S\'inscrire', 'log_in' => 'Se connecter', - 'log_in_with' => 'Se connecter avec :socialDriver', + 'log_in_with' => 'Connexion avec :socialDriver', 'sign_up_with' => 'S\'inscrire avec :socialDriver', - 'logout' => 'Se déconnecter', + 'logout' => 'Déconnexion', 'name' => 'Nom', 'username' => 'Nom d\'utilisateur', diff --git a/lang/fr/entities.php b/lang/fr/entities.php index d650fc830..fae7480b1 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Déplacer les pages et chapitres au sein d’un livre pour en réorganiser le contenu. D’autres livres peuvent être ajoutés pour faciliter le déplacement des pages et chapitres entre les livres. Facultativement, une règle de tri automatique peut être mise en place afin de trier le livre lorsqu’il est édité.', 'books_sort_auto_sort' => 'Option de tri automatique', 'books_sort_auto_sort_active' => 'Tri automatique actif : :sortName', + 'books_sort_auto_sort_creation_hint' => 'Les règles de tri automatiques peuvent être créées dans la section « Listes et tri » des préférences par un utilisateur disposant des autorisations appropriées.', 'books_sort_named' => 'Trier le livre :bookName', 'books_sort_name' => 'Trier par le nom', 'books_sort_created' => 'Trier par la date de création', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index 8c6c57f33..664184a7d 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Tous', 'role_own' => 'Propres', 'role_controlled_by_asset' => 'Contrôlé par les ressources les ayant envoyés', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Enregistrer le rôle', 'role_users' => 'Utilisateurs ayant ce rôle', 'role_users_none' => 'Aucun utilisateur avec ce rôle actuellement', diff --git a/lang/he/entities.php b/lang/he/entities.php index d8b264d8b..38ffc95cf 100644 --- a/lang/he/entities.php +++ b/lang/he/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'מיין את הספר :bookName', 'books_sort_name' => 'מיין לפי שם', 'books_sort_created' => 'מיין לפי תאריך יצירה', diff --git a/lang/he/settings.php b/lang/he/settings.php index 46150081a..b8f204813 100644 --- a/lang/he/settings.php +++ b/lang/he/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'הכל', 'role_own' => 'שלי', 'role_controlled_by_asset' => 'נשלטים על ידי המשאב אליו הועלו', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'שמור תפקיד', 'role_users' => 'משתמשים משוייכים לתפקיד זה', 'role_users_none' => 'אין משתמשים המשוייכים לתפקיד זה', diff --git a/lang/hr/entities.php b/lang/hr/entities.php index 99df50ea3..ff51c1b2f 100644 --- a/lang/hr/entities.php +++ b/lang/hr/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Razvrstaj knjigu :bookName', 'books_sort_name' => 'Razvrstaj po imenu', 'books_sort_created' => 'Razvrstaj po datumu nastanka', diff --git a/lang/hr/settings.php b/lang/hr/settings.php index 0692c8d7a..60b2485ef 100644 --- a/lang/hr/settings.php +++ b/lang/hr/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Sve', 'role_own' => 'Vlastito', 'role_controlled_by_asset' => 'Kontrolirano od strane vlasnika', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Spremi ulogu', 'role_users' => 'Korisnici u ovoj ulozi', 'role_users_none' => 'Trenutno nijedan korisnik nije u ovoj ulozi', diff --git a/lang/hu/entities.php b/lang/hu/entities.php index 570120dfa..707de62d8 100644 --- a/lang/hu/entities.php +++ b/lang/hu/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => ':bookName könyv rendezése', 'books_sort_name' => 'Rendezés név szerint', 'books_sort_created' => 'Rendezés létrehozás dátuma szerint', diff --git a/lang/hu/settings.php b/lang/hu/settings.php index 53b1cdcc4..aaccbd35f 100644 --- a/lang/hu/settings.php +++ b/lang/hu/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Összes', 'role_own' => 'Saját', 'role_controlled_by_asset' => 'Az általuk feltöltött eszköz által ellenőrzött', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Szerepkör mentése', 'role_users' => 'Felhasználók ebben a szerepkörben', 'role_users_none' => 'Jelenleg nincsenek felhasználók hozzárendelve ehhez a szerepkörhöz', diff --git a/lang/id/entities.php b/lang/id/entities.php index 2f9329925..d978a3d19 100644 --- a/lang/id/entities.php +++ b/lang/id/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sortir Buku :bookName', 'books_sort_name' => 'Diurutkan berdasarkan nama', 'books_sort_created' => 'Urutkan berdasarkan Tanggal Dibuat', diff --git a/lang/id/settings.php b/lang/id/settings.php index 8bdd99e68..fe3289ffa 100644 --- a/lang/id/settings.php +++ b/lang/id/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Semua', 'role_own' => 'Sendiri', 'role_controlled_by_asset' => 'Dikendalikan oleh aset tempat mereka diunggah', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Simpan Peran', 'role_users' => 'Peran berhasil diperbarui', 'role_users_none' => 'Saat ini tidak ada pengguna yang ditugaskan untuk peran ini', diff --git a/lang/is/entities.php b/lang/is/entities.php index 4308835c7..0d9a6fc1e 100644 --- a/lang/is/entities.php +++ b/lang/is/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Raða bók :bookName', 'books_sort_name' => 'Raða eftir nafni', 'books_sort_created' => 'Raða eftir skráningar dagsetningu', diff --git a/lang/is/settings.php b/lang/is/settings.php index b1f21ac10..877e0dfa8 100644 --- a/lang/is/settings.php +++ b/lang/is/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Allt', 'role_own' => 'Eigin', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Vista hlutverk', 'role_users' => 'Notendur í þessu hlutverki', 'role_users_none' => 'Engir notendur eru eins og er í þessu hlutverki', diff --git a/lang/it/entities.php b/lang/it/entities.php index eb9868973..1cce5fcd5 100644 --- a/lang/it/entities.php +++ b/lang/it/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Spostare i capitoli e le pagine di un libro per riorganizzarne il contenuto. Possono essere aggiunti altri libri che permettono di spostare facilmente capitoli e pagine tra i libri. Opzionalmente una regola di ordinamento automatico può essere impostata per ordinare automaticamente i contenuti di questo libro in caso di modifiche.', 'books_sort_auto_sort' => 'Opzione Ordinamento Automatico', 'books_sort_auto_sort_active' => 'Ordinamento Automatico Attivo: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Ordina il libro :bookName', 'books_sort_name' => 'Ordina per Nome', 'books_sort_created' => 'Ordina per Data di creazione', diff --git a/lang/it/settings.php b/lang/it/settings.php index 2b5819b2a..9a272db33 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Tutti', 'role_own' => 'Propri', 'role_controlled_by_asset' => 'Controllato dall\'entità in cui sono caricati', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Salva ruolo', 'role_users' => 'Utenti in questo ruolo', 'role_users_none' => 'Nessun utente assegnato a questo ruolo', diff --git a/lang/ja/entities.php b/lang/ja/entities.php index 551a2e496..57703997f 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'ブック内のチャプタおよびページを移動して内容を再編成できます。他のブックを並べて、ブック間でチャプタやページを簡単に移動することもできます。オプションで自動ソートルールを設定すると、変更時にブックの内容を自動的にソートすることができます。', 'books_sort_auto_sort' => '自動ソートオプション', 'books_sort_auto_sort_active' => '自動ソート有効: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'ブック「:bookName」を並べ替え', 'books_sort_name' => '名前で並べ替え', 'books_sort_created' => '作成日で並べ替え', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index 378e3a774..a3cbf696a 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => '全て', 'role_own' => '自身', 'role_controlled_by_asset' => 'このアセットに対し、右記の操作を許可:', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => '役割を保存', 'role_users' => 'この役割を持つユーザー', 'role_users_none' => 'この役割が付与されたユーザーはいません', diff --git a/lang/ka/entities.php b/lang/ka/entities.php index 74c50be3b..5501d2bc2 100644 --- a/lang/ka/entities.php +++ b/lang/ka/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', diff --git a/lang/ka/settings.php b/lang/ka/settings.php index c4d1eb136..3937c650f 100644 --- a/lang/ka/settings.php +++ b/lang/ka/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Save Role', 'role_users' => 'Users in this role', 'role_users_none' => 'No users are currently assigned to this role', diff --git a/lang/ko/entities.php b/lang/ko/entities.php index 44809d99f..c54d6c7dc 100644 --- a/lang/ko/entities.php +++ b/lang/ko/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => '책 내의 챕터와 페이지를 이동하여 콘텐츠를 재구성할 수 있습니다. 다른 책들을 추가하여 책 간의 챕터와 페이지를 쉽게 이동할 수 있습니다. 선택적으로 자동 정렬 규칙을 설정하여 변경 시 이 책의 콘텐츠를 자동으로 정렬할 수 있습니다.', 'books_sort_auto_sort' => '자동 정렬 옵션', 'books_sort_auto_sort_active' => '현재 설정된 자동 정렬: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => ':bookName 정렬', 'books_sort_name' => '제목', 'books_sort_created' => '만든 날짜', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 97af673c6..9aabe5c2a 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => '모든 항목', 'role_own' => '직접 만든 항목', 'role_controlled_by_asset' => '저마다 다름', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => '저장', 'role_users' => '이 역할을 가진 사용자들', 'role_users_none' => '역할이 부여된 사용자가 없습니다.', diff --git a/lang/ku/entities.php b/lang/ku/entities.php index 74c50be3b..5501d2bc2 100644 --- a/lang/ku/entities.php +++ b/lang/ku/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', diff --git a/lang/ku/settings.php b/lang/ku/settings.php index c4d1eb136..3937c650f 100644 --- a/lang/ku/settings.php +++ b/lang/ku/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Save Role', 'role_users' => 'Users in this role', 'role_users_none' => 'No users are currently assigned to this role', diff --git a/lang/lt/entities.php b/lang/lt/entities.php index 6c4472d85..f6610a22a 100644 --- a/lang/lt/entities.php +++ b/lang/lt/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Rūšiuoti knygą :bookName', 'books_sort_name' => 'Rūšiuoti pagal vardą', 'books_sort_created' => 'Rūšiuoti pagal sukūrimo datą', diff --git a/lang/lt/settings.php b/lang/lt/settings.php index f797e567e..23dd38d50 100644 --- a/lang/lt/settings.php +++ b/lang/lt/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Visi', 'role_own' => 'Nuosavi', 'role_controlled_by_asset' => 'Kontroliuojami nuosavybės, į kurią yra įkelti', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Išsaugoti vaidmenį', 'role_users' => 'Naudotojai šiame vaidmenyje', 'role_users_none' => 'Šiuo metu prie šio vaidmens nėra priskirta naudotojų', diff --git a/lang/lv/entities.php b/lang/lv/entities.php index 5f921048e..f0f32ab21 100644 --- a/lang/lv/entities.php +++ b/lang/lv/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Kārtot grāmatu :bookName', 'books_sort_name' => 'Kārtot pēc nosaukuma', 'books_sort_created' => 'Kārtot pēc izveidošanas datuma', diff --git a/lang/lv/settings.php b/lang/lv/settings.php index 9dc6bf402..0e11ebd65 100644 --- a/lang/lv/settings.php +++ b/lang/lv/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Visi', 'role_own' => 'Savi', 'role_controlled_by_asset' => 'Kontrolē resurss, uz ko tie ir augšupielādēti', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Saglabāt grupu', 'role_users' => 'Lietotāji šajā grupā', 'role_users_none' => 'Pagaidām neviens lietotājs nav pievienots šai grupai', diff --git a/lang/nb/entities.php b/lang/nb/entities.php index cf0fdffde..a67c35e17 100644 --- a/lang/nb/entities.php +++ b/lang/nb/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Flytt kapitler og sider innen en bok for å reorganisere innholdet. Andre bøker kan legges til, noe som gjør det enkelt å flytte kapitler og sider mellom bøkene. Valgfritt kan en automatisk sorteringsregel settes for å automatisk sortere innholdet i denne boken ved endringer.', 'books_sort_auto_sort' => 'Automatisk sorteringsalternativ', 'books_sort_auto_sort_active' => 'Automatisk sortering aktiv: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Omorganisér :bookName (bok)', 'books_sort_name' => 'Sorter på navn', 'books_sort_created' => 'Sorter på opprettet dato', diff --git a/lang/nb/settings.php b/lang/nb/settings.php index 5fcaaaca6..5e779beea 100644 --- a/lang/nb/settings.php +++ b/lang/nb/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Alle', 'role_own' => 'Egne', 'role_controlled_by_asset' => 'Kontrollert av eiendelen de er lastet opp til', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Lagre rolle', 'role_users' => 'Kontoholdere med denne rollen', 'role_users_none' => 'Ingen kontoholdere er gitt denne rollen', diff --git a/lang/ne/entities.php b/lang/ne/entities.php index 88b202dee..4d9f78ea5 100644 --- a/lang/ne/entities.php +++ b/lang/ne/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'पुस्तकमा अध्यायहरू र पृष्ठहरूलाई पुनः व्यवस्थित गर्नका लागि सार्नुहोस्। अन्य पुस्तकहरू थप्न सकिन्छ जसले अध्याय र पृष्ठहरूलाई पुस्तकहरू बीच सजिलै सर्न मद्दत गर्दछ। वैकल्पिक रूपमा एक स्वचालित वर्गीकरण नियम सेट गर्न सकिन्छ जसले पुस्तकको सामग्रीहरू परिवर्तन भएपछि स्वत: वर्गीकृत गर्छ।', 'books_sort_auto_sort' => 'स्वचालित वर्गीकरण विकल्प', 'books_sort_auto_sort_active' => 'स्वचालित वर्गीकरण सक्रिय: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'पुस्तक :bookName को वर्गीकरण गर्नुहोस्', 'books_sort_name' => 'नाम अनुसार वर्गीकृत गर्नुहोस्', 'books_sort_created' => 'सिर्जना मितिअनुसार वर्गीकृत गर्नुहोस्', diff --git a/lang/ne/settings.php b/lang/ne/settings.php index dbc7d8e9f..f52bcd420 100644 --- a/lang/ne/settings.php +++ b/lang/ne/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'सबै', 'role_own' => 'आफ्नो', 'role_controlled_by_asset' => 'अपलोड गरिएको सम्पत्तिले नियन्त्रण गरेको', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'भूमिका सुरक्षित गर्नुहोस्', 'role_users' => 'यस भूमिकाका प्रयोगकर्ताहरू', 'role_users_none' => 'यो भूमिकामा हाल कुनै प्रयोगकर्ता छैन', diff --git a/lang/nl/entities.php b/lang/nl/entities.php index b9be1f42b..c39beef2d 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Verplaats hoofdstukken en pagina\'s door het boek om ze te organiseren. Andere boeken kunnen worden toegevoegd zodat hoofdstukken en pagina\'s gemakkelijk tussen boeken kunnen worden verplaatst. Het is mogelijk om een automatische sorteerregel in te stellen die de inhoud zal sorteren bij wijzigingen.', 'books_sort_auto_sort' => 'Automatisch Sorteren', 'books_sort_auto_sort_active' => 'Automatisch Sorteren Actief: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sorteer boek :bookName', 'books_sort_name' => 'Sorteren op naam', 'books_sort_created' => 'Sorteren op datum van aanmaken', diff --git a/lang/nl/settings.php b/lang/nl/settings.php index c8d071119..b82c52deb 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Alles', 'role_own' => 'Eigen', 'role_controlled_by_asset' => 'Gecontroleerd door de asset waar deze is geüpload', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Rol Opslaan', 'role_users' => 'Gebruikers in deze rol', 'role_users_none' => 'Geen enkele gebruiker heeft deze rol', diff --git a/lang/nn/entities.php b/lang/nn/entities.php index 4c75d5019..4f79f3d75 100644 --- a/lang/nn/entities.php +++ b/lang/nn/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Omorganiser :bookName', 'books_sort_name' => 'Sorter på namn', 'books_sort_created' => 'Sorter på oppretta dato', diff --git a/lang/nn/settings.php b/lang/nn/settings.php index e4b6e6af9..b6c322c9c 100644 --- a/lang/nn/settings.php +++ b/lang/nn/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Alle', 'role_own' => 'Egne', 'role_controlled_by_asset' => 'Kontrollert av eiendelen de er lastet opp til', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Lagre rolle', 'role_users' => 'Kontoholdere med denne rollen', 'role_users_none' => 'Ingen kontoholdere er gitt denne rollen', diff --git a/lang/pl/entities.php b/lang/pl/entities.php index f3ad807af..88e4344de 100644 --- a/lang/pl/entities.php +++ b/lang/pl/entities.php @@ -173,6 +173,7 @@ return [ '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' => 'Opcja automatycznego sortowania', 'books_sort_auto_sort_active' => 'Automatyczne sortowanie aktywne: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sortuj książkę :bookName', 'books_sort_name' => 'Sortuj według nazwy', 'books_sort_created' => 'Sortuj według daty utworzenia', diff --git a/lang/pl/settings.php b/lang/pl/settings.php index 775d4f25d..98201406e 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Wszyscy', 'role_own' => 'Własne', 'role_controlled_by_asset' => 'Kontrolowane przez zasób, do którego zostały udostępnione', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Zapisz rolę', 'role_users' => 'Użytkownicy w tej roli', 'role_users_none' => 'Brak użytkowników zapisanych do tej roli', diff --git a/lang/pt/entities.php b/lang/pt/entities.php index e882c79d0..709b8466a 100644 --- a/lang/pt/entities.php +++ b/lang/pt/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Mova capítulos e páginas de um livro para reorganizar o seu conteúdo. É possível acrescentar outros livros, o que permite uma movimentação fácil de capítulos e páginas entre livros. Opcionalmente, uma regra de organização automática pode ser definida para classificar automaticamente o conteúdo deste livro após alterações.', 'books_sort_auto_sort' => '', 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Ordenar Livro :bookName', 'books_sort_name' => 'Ordenar por Nome', 'books_sort_created' => 'Ordenar por Data de Criação', diff --git a/lang/pt/settings.php b/lang/pt/settings.php index a59335b7c..47fecbcbf 100644 --- a/lang/pt/settings.php +++ b/lang/pt/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Todos', 'role_own' => 'Próprio', 'role_controlled_by_asset' => 'Controlado pelo ativo para o qual eles são enviados', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Guardar Cargo', 'role_users' => 'Utilizadores com este cargo', 'role_users_none' => 'Nenhum utilizador está atualmente vinculado a este cargo', diff --git a/lang/pt_BR/auth.php b/lang/pt_BR/auth.php index b5fd974aa..0580542b4 100644 --- a/lang/pt_BR/auth.php +++ b/lang/pt_BR/auth.php @@ -89,7 +89,7 @@ return [ 'mfa_setup_action' => 'Configurações', 'mfa_backup_codes_usage_limit_warning' => 'Você tem menos de 5 códigos de backup restantes, Por favor, gere e armazene um novo conjunto antes de esgotar suas opções de códigos de backup para evitar estar bloqueado para fora da sua conta.', 'mfa_option_totp_title' => 'Aplicativo Móvel', - 'mfa_option_totp_desc' => 'Para usar a autenticação multi-fator, você precisará de um aplicativo móvel que suporte TOTP como o Google Authenticator, Authy ou o Microsoft Authenticator.', + 'mfa_option_totp_desc' => 'Para usar a autenticação multi-fator, você precisará de um aplicativo móvel que suporte TOTP como o Google Authenticator, Authy, Microsoft Authenticator ou Proton Authenticator.', 'mfa_option_backup_codes_title' => 'Códigos de backup', 'mfa_option_backup_codes_desc' => 'Gera um conjunto de códigos de backup de uso único que você inserirá no login para verificar sua identidade. Certifique-se de armazená-los em um local seguro e protegido.', 'mfa_gen_confirm_and_enable' => 'Confirmar e habilitar', diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index 5a948c8dc..0211f45fa 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Mova capítulos e páginas de um livro para reorganizar seu conteúdo. É possível acrescentar outros livros, o que permite uma movimentação fácil de capítulos e páginas entre livros. Opcionalmente, uma regra de ordenação automática pode ser definida para ordenar automaticamente o conteúdo deste livro após alterações.', 'books_sort_auto_sort' => 'Opção de ordenação automática', 'books_sort_auto_sort_active' => 'Ordenação automática ativa: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Regras de ordenação automática podem ser criadas na área de configurações "Lista e Classificação" por um usuário com as permissões relevantes.', 'books_sort_named' => 'Ordenar Livro :bookName', 'books_sort_name' => 'Ordernar por Nome', 'books_sort_created' => 'Ordenar por Data de Criação', diff --git a/lang/pt_BR/settings.php b/lang/pt_BR/settings.php index 97b434727..9b7c6a7d4 100644 --- a/lang/pt_BR/settings.php +++ b/lang/pt_BR/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Todos', 'role_own' => 'Próprio', 'role_controlled_by_asset' => 'Controlado pelos ativos nos quais o upload foi realizado', + 'role_controlled_by_page_delete' => 'Controlado pelas permissões de exclusão de página', 'role_save' => 'Salvar Perfil', 'role_users' => 'Usuários com este perfil', 'role_users_none' => 'Nenhum usuário está atualmente vinculado a este perfil', diff --git a/lang/ro/entities.php b/lang/ro/entities.php index ac8c9d326..aa18d09fb 100644 --- a/lang/ro/entities.php +++ b/lang/ro/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sortează cartea :bookName', 'books_sort_name' => 'Sortează după nume', 'books_sort_created' => 'Sortează după data creării', diff --git a/lang/ro/settings.php b/lang/ro/settings.php index d65a8e071..117c4ca61 100644 --- a/lang/ro/settings.php +++ b/lang/ro/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Tot', 'role_own' => 'Propriu', 'role_controlled_by_asset' => 'Controlat de activele pe care sunt încărcate', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Salvare rol', 'role_users' => 'Utilizatori cu acest rol', 'role_users_none' => 'Nici un utilizator nu este asociat acestui rol', diff --git a/lang/ru/activities.php b/lang/ru/activities.php index d353b7a76..d9ac1685b 100644 --- a/lang/ru/activities.php +++ b/lang/ru/activities.php @@ -85,12 +85,12 @@ return [ 'webhook_delete_notification' => 'Вебхук успешно удален', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', + 'import_create' => 'создал импорт', + 'import_create_notification' => 'Импорт успешно добавлен', 'import_run' => 'обновлен импорт', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_run_notification' => 'Контент успешно импортирован', + 'import_delete' => 'удалил импорт', + 'import_delete_notification' => 'Импорт успешно удален', // Users 'user_create' => 'создал пользователя', diff --git a/lang/ru/common.php b/lang/ru/common.php index fa9969c32..bf77a999d 100644 --- a/lang/ru/common.php +++ b/lang/ru/common.php @@ -30,8 +30,8 @@ return [ 'create' => 'Создание', 'update' => 'Обновление', 'edit' => 'Редактировать', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => 'Архивировать', + 'unarchive' => 'Вернуть из архива', 'sort' => 'Сортировать', 'move' => 'Переместить', 'copy' => 'Скопировать', diff --git a/lang/ru/editor.php b/lang/ru/editor.php index d26f7edfe..da661559b 100644 --- a/lang/ru/editor.php +++ b/lang/ru/editor.php @@ -149,7 +149,7 @@ return [ 'url' => 'URL-адрес', 'text_to_display' => 'Текст для отображения', 'title' => 'Заголовок', - 'browse_links' => 'Browse links', + 'browse_links' => 'Просмотр ссылки', 'open_link' => 'Открыть ссылку', 'open_link_in' => 'Открыть ссылку в...', 'open_link_current' => 'В текущем окне', @@ -166,8 +166,8 @@ return [ 'about' => 'О редакторе', 'about_title' => 'О редакторе WYSIWYG', 'editor_license' => 'Лицензия редактора и авторские права', - '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' => 'Этот редактор создан с помощью :lexicalLink, распространяемый под лицензией MIT.', + 'editor_lexical_license_link' => 'Здесь вы можете найти полную информацию о лицензии.', 'editor_tiny_license' => 'Этот редактор собран с помощью :tinyLink, который предоставляется под MIT лицензией.', 'editor_tiny_license_link' => 'Авторские права и подробности лицензии TinyMCE вы можете найти здесь.', 'save_continue' => 'Сохранить страницу и продолжить', diff --git a/lang/ru/entities.php b/lang/ru/entities.php index bf224c10a..28d096fe8 100644 --- a/lang/ru/entities.php +++ b/lang/ru/entities.php @@ -51,18 +51,18 @@ return [ 'import_pending' => 'Ожидается импорт', 'import_pending_none' => 'Импорт не был запущен.', 'import_continue' => 'Продолжить импорт', - '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_continue_desc' => 'Проверьте содержимое, которое должно быть импортировано из загруженного ZIP-файла. Если все готово, выполните импорт, чтобы добавить его содержимое в эту систему. Загруженный ZIP файл будет автоматически удален при успешном импорте.', + 'import_details' => 'Детали загрузки', 'import_run' => 'Запустить импорт', 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', + 'import_uploaded_at' => 'Загружено :relativeTime', + 'import_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_location_desc' => 'Выберите целевое местоположение для импортированного содержимого. Для создания в выбранном месте необходимы соответствующие разрешения.', '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_delete_desc' => 'Это приведет к удалению загруженного ZIP файла и не может быть отменено.', 'import_errors' => 'Ошибки импорта', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + 'import_errors_desc' => 'Во время попытки импорта произошла следующая ошибка:', 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', @@ -170,9 +170,10 @@ return [ 'books_search_this' => 'Поиск в этой книге', 'books_navigation' => 'Навигация по книге', 'books_sort' => 'Сортировка содержимого книги', - '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_desc' => 'Переместите разделы и страницы в книге, чтобы изменить содержание книги. Могут быть добавлены другие книги, что позволяет легко перемещать разделы и страницы между книгами. При желании правило автоматической сортировки может быть установлено для автоматической сортировки содержимого этой книги после изменений.', + 'books_sort_auto_sort' => 'Автосортировка', + 'books_sort_auto_sort_active' => 'Автосортировка активна: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Правила автоматической сортировки могут быть созданы в области настроек «Список и Сортировка» пользователем с соответствующими разрешениями.', 'books_sort_named' => 'Сортировка книги :bookName', 'books_sort_name' => 'По имени', 'books_sort_created' => 'По дате создания', @@ -251,7 +252,7 @@ return [ 'pages_edit_switch_to_markdown_clean' => 'Только Markdown (с возможными потерями форматирования)', 'pages_edit_switch_to_markdown_stable' => 'Полное сохранение форматирования (HTML)', 'pages_edit_switch_to_wysiwyg' => 'Переключиться в WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg' => 'Переключиться на новый WYSIWYG', 'pages_edit_switch_to_new_wysiwyg_desc' => '(В бета-тестировании)', 'pages_edit_set_changelog' => 'Задать список изменений', 'pages_edit_enter_changelog_desc' => 'Введите краткое описание внесенных изменений', diff --git a/lang/ru/notifications.php b/lang/ru/notifications.php index 96e853723..ea885d910 100644 --- a/lang/ru/notifications.php +++ b/lang/ru/notifications.php @@ -12,7 +12,7 @@ return [ 'updated_page_intro' => 'Страница была обновлена в :appName:', 'updated_page_debounce' => 'Чтобы предотвратить массовые уведомления, в течение некоторого времени вы не будете получать уведомления о дальнейших правках этой страницы этим же редактором.', 'comment_mention_subject' => 'Вы были упомянуты в комментарии на странице: :pageName', - 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', + 'comment_mention_intro' => 'Вы были упомянуты в комментариях к :appName:', 'detail_page_name' => 'Имя страницы:', 'detail_page_path' => 'Путь страницы:', diff --git a/lang/ru/preferences.php b/lang/ru/preferences.php index b61b252c8..2dcd20b11 100644 --- a/lang/ru/preferences.php +++ b/lang/ru/preferences.php @@ -23,7 +23,7 @@ return [ 'notifications_desc' => 'Управляйте полученными по электронной почте уведомлениями при выполнении определенных действий в системе.', 'notifications_opt_own_page_changes' => 'Уведомлять об изменениях в собственных страницах', 'notifications_opt_own_page_comments' => 'Уведомлять о комментариях на собственных страницах', - 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', + 'notifications_opt_comment_mentions' => 'Уведомлять, когда меня упоминали в комментарии', 'notifications_opt_comment_replies' => 'Уведомлять об ответах на мои комментарии', 'notifications_save' => 'Сохранить настройки', 'notifications_update_success' => 'Настройки уведомлений были обновлены!', diff --git a/lang/ru/settings.php b/lang/ru/settings.php index 76a2eebbf..7bf5832a3 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -82,28 +82,28 @@ return [ 'sorting_rules_desc' => 'Выберите правило сортировки по умолчанию для новых книг. Это не повлияет на существующие книги и может быть изменено для каждой книги отдельно.', 'sort_rule_assigned_to_x_books' => 'Используется в :count книгах', 'sort_rule_create' => 'Создать правило сортировки', - 'sort_rule_edit' => 'Edit Sort Rule', + 'sort_rule_edit' => 'Изменить правило сортировки', 'sort_rule_delete' => 'Удалить правило сортировки', 'sort_rule_delete_desc' => 'Удалить это правило сортировки из системы. Книги, использующие эту сортировку, вернутся к ручной сортировке.', - '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_books' => 'Это правило сортировки в настоящее время используется в :count book(s). Вы уверены, что хотите удалить его?', 'sort_rule_delete_warn_default' => 'Это правило сортировки используется по умолчанию для книг. Вы уверены, что хотите удалить его?', '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_details_desc' => 'Задайте имя для этого правила сортировки, оно будет отображаться в списках, когда пользователи выбирают сортировку.', 'sort_rule_operations' => 'Sort Operations', - 'sort_rule_operations_desc' => 'Configure the sort actions to be performed by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom. Any changes made here will be applied to all assigned books upon save.', + 'sort_rule_operations_desc' => 'Настройка сортировки выполняемых действий путем перемещения их из списка доступных операций. После использования операции будут выполняться по порядку сверху вниз. Любые изменения, внесенные здесь, будут применены ко всем назначенным книгам после сохранения.', 'sort_rule_available_operations' => 'Доступные операции', - 'sort_rule_available_operations_empty' => 'No operations remaining', + 'sort_rule_available_operations_empty' => 'Операций не осталось', 'sort_rule_configured_operations' => 'Configured Operations', 'sort_rule_configured_operations_empty' => 'Перетащите/добавьте операции из списка "Доступные операции"', 'sort_rule_op_asc' => '(Возрастание)', 'sort_rule_op_desc' => '(Убывание)', - 'sort_rule_op_name' => 'Name - Alphabetical', + 'sort_rule_op_name' => 'Имя - по алфавиту', 'sort_rule_op_name_numeric' => 'По нумерации', - 'sort_rule_op_created_date' => 'Created Date', - 'sort_rule_op_updated_date' => 'Updated Date', + 'sort_rule_op_created_date' => 'По дате создания', + 'sort_rule_op_updated_date' => 'По дате обновления', 'sort_rule_op_chapters_first' => 'Главы в начале', 'sort_rule_op_chapters_last' => 'Главы в конце', - 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits' => 'Ограничения показа на странице', 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using a multiple of 6 is recommended.', // Maintenance settings @@ -194,19 +194,20 @@ return [ 'role_access_api' => 'Доступ к системному API', 'role_manage_settings' => 'Управление настройками приложения', 'role_export_content' => 'Экспорт контента', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Импортировать содержимое', 'role_editor_change' => 'Изменение редактора страниц', 'role_notifications' => 'Получение и управление уведомлениями', - 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', + 'role_permission_note_users_and_roles' => 'Эти разрешения также обеспечивают видимость и поиск пользователей и ролей в системе.', 'role_asset' => 'Права доступа к материалам', 'roles_system_warning' => 'Имейте в виду, что доступ к любому из указанных выше трех разрешений может позволить пользователю изменить свои собственные привилегии или привилегии других пользователей системы. Назначать роли с этими правами можно только доверенным пользователям.', 'role_asset_desc' => 'Эти разрешения контролируют доступ по умолчанию к параметрам внутри системы. Разрешения на книги, главы и страницы перезапишут эти разрешения.', 'role_asset_admins' => 'Администраторы автоматически получают доступ ко всему контенту, но эти опции могут отображать или скрывать параметры пользовательского интерфейса.', 'role_asset_image_view_note' => 'Это относится к видимости в менеджере изображений. Фактический доступ к загруженным файлам изображений будет зависеть от опции хранения системных изображений.', - 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', + 'role_asset_users_note' => 'Эти разрешения также обеспечивают видимость и поиск пользователей в системе.', 'role_all' => 'Все', 'role_own' => 'Владелец', 'role_controlled_by_asset' => 'Контролируется активом, в который они загружены', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Сохранить роль', 'role_users' => 'Пользователи с данной ролью', 'role_users_none' => 'Нет пользователей с данной ролью', diff --git a/lang/ru/validation.php b/lang/ru/validation.php index ce94faa3b..6d011cff1 100644 --- a/lang/ru/validation.php +++ b/lang/ru/validation.php @@ -105,11 +105,11 @@ return [ 'url' => 'Формат :attribute некорректен.', 'uploaded' => 'Не удалось загрузить файл. Сервер не может принимать файлы такого размера.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_size' => 'The file :attribute must not exceed :size MB.', - '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' => ':attribute должен ссылаться на файл внутри ZIP.', + 'zip_file_size' => 'Файл :attribute не должен превышать :size МБ.', + 'zip_file_mime' => ':attribute должен ссылаться на файл типа :validTypes, найден :foundType.', + 'zip_model_expected' => 'Ожидался объект данных, но найдено ":type".', + 'zip_unique' => 'Значение :attribute должно быть уникальным для типа объекта внутри ZIP.', // Custom validation lines 'custom' => [ diff --git a/lang/sk/entities.php b/lang/sk/entities.php index eb5254535..48b662bf3 100644 --- a/lang/sk/entities.php +++ b/lang/sk/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Zoradiť knihu :bookName', 'books_sort_name' => 'Zoradiť podľa mena', 'books_sort_created' => 'Zoradiť podľa dátumu vytvorenia', diff --git a/lang/sk/settings.php b/lang/sk/settings.php index 67671f6f8..e18801ff4 100644 --- a/lang/sk/settings.php +++ b/lang/sk/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Všetko', 'role_own' => 'Vlastné', 'role_controlled_by_asset' => 'Regulované zdrojom, do ktorého sú nahrané', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Uložiť rolu', 'role_users' => 'Používatelia s touto rolou', 'role_users_none' => 'Žiadni používatelia nemajú priradenú túto rolu', diff --git a/lang/sl/entities.php b/lang/sl/entities.php index 00ac99181..86a43132e 100644 --- a/lang/sl/entities.php +++ b/lang/sl/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Razvrsti knjigo :bookName', 'books_sort_name' => 'Razvrsti po imenu', 'books_sort_created' => 'Razvrsti po datumu nastanka', diff --git a/lang/sl/settings.php b/lang/sl/settings.php index 947621389..87c1e8e6d 100644 --- a/lang/sl/settings.php +++ b/lang/sl/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Vse', 'role_own' => 'Lasten', 'role_controlled_by_asset' => 'Nadzira ga sredstvo, v katerega so naloženi', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Shrani vlogo', 'role_users' => 'Uporabniki v tej vlogi', 'role_users_none' => 'Tej vlogi trenutno ni dodeljen noben uporabnik', diff --git a/lang/sq/entities.php b/lang/sq/entities.php index 74c50be3b..5501d2bc2 100644 --- a/lang/sq/entities.php +++ b/lang/sq/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', diff --git a/lang/sq/settings.php b/lang/sq/settings.php index c4d1eb136..3937c650f 100644 --- a/lang/sq/settings.php +++ b/lang/sq/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Save Role', 'role_users' => 'Users in this role', 'role_users_none' => 'No users are currently assigned to this role', diff --git a/lang/sr/entities.php b/lang/sr/entities.php index 151edee40..8f9c40e91 100644 --- a/lang/sr/entities.php +++ b/lang/sr/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', diff --git a/lang/sr/settings.php b/lang/sr/settings.php index f6c86827e..3453bc344 100644 --- a/lang/sr/settings.php +++ b/lang/sr/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Save Role', 'role_users' => 'Users in this role', 'role_users_none' => 'No users are currently assigned to this role', diff --git a/lang/sv/entities.php b/lang/sv/entities.php index 680e0908a..94fc23f50 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Flytta kapitel och sidor inom en bok för att omorganisera dess innehåll. Andra böcker kan läggas till, vilket gör det enkelt att flytta kapitel och sidor mellan böcker. Du kan även ställa in en regel som automatiskt sorterar bokens innehåll vid ändringar.', 'books_sort_auto_sort' => 'Automatiskt sorteringsalternativ', 'books_sort_auto_sort_active' => 'Aktiv automatisk sorteringsregel: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sortera boken :bookName', 'books_sort_name' => 'Sortera utifrån namn', 'books_sort_created' => 'Sortera utifrån skapelse', diff --git a/lang/sv/settings.php b/lang/sv/settings.php index 773c4bff3..47f602b8d 100644 --- a/lang/sv/settings.php +++ b/lang/sv/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Alla', 'role_own' => 'Egna', 'role_controlled_by_asset' => 'Kontrolleras av den sida de laddas upp till', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Spara roll', 'role_users' => 'Användare med denna roll', 'role_users_none' => 'Inga användare tillhör den här rollen', diff --git a/lang/th/activities.php b/lang/th/activities.php new file mode 100644 index 000000000..6d84813d6 --- /dev/null +++ b/lang/th/activities.php @@ -0,0 +1,140 @@ + 'สร้างหน้า', + 'page_create_notification' => 'สร้างหน้าสำเร็จแล้ว', + 'page_update' => 'แก้ไขหน้า', + 'page_update_notification' => 'แก้ไขหน้าสำเร็จแล้ว', + 'page_delete' => 'ลบหน้า', + 'page_delete_notification' => 'ลบหน้าสำเร็จแล้ว', + 'page_restore' => 'กู้คืนหน้า', + 'page_restore_notification' => 'กู้คืนหน้าสำเร็จแล้ว', + 'page_move' => 'ย้ายหน้า', + 'page_move_notification' => 'ย้ายหน้าสำเร็จแล้ว', + + // Chapters + 'chapter_create' => 'สร้างบท', + 'chapter_create_notification' => 'สร้างบทสำเร็จแล้ว', + 'chapter_update' => 'แก้ไขบท', + 'chapter_update_notification' => 'แก้ไขบทสำเร็จแล้ว', + 'chapter_delete' => 'ลบบท', + 'chapter_delete_notification' => 'ลบบทสำเร็จแล้ว', + 'chapter_move' => 'ย้ายบท', + 'chapter_move_notification' => 'ย้ายบทสำเร็จแล้ว', + + // Books + 'book_create' => 'สร้างหนังสือ', + 'book_create_notification' => 'สร้างหนังสือสำเร็จแล้ว', + 'book_create_from_chapter' => 'แปลงบทเป็นหนังสือ', + 'book_create_from_chapter_notification' => 'แปลงบทเป็นหนังสือสำเร็จแล้ว', + 'book_update' => 'แก้ไขหนังสือ', + 'book_update_notification' => 'แก้ไขหนังสือสำเร็จแล้ว', + 'book_delete' => 'ลบหนังสือ', + 'book_delete_notification' => 'ลบหนังสือสำเร็จแล้ว', + 'book_sort' => 'จัดเรียงหนังสือ', + 'book_sort_notification' => 'จัดเรียงหนังสือสำเร็จแล้ว', + + // Bookshelves + 'bookshelf_create' => 'สร้างชั้นวาง', + 'bookshelf_create_notification' => 'สร้างชั้นวางสำเร็จแล้ว', + 'bookshelf_create_from_book' => 'แปลงหนังสือเป็นชั้นวาง', + 'bookshelf_create_from_book_notification' => 'แปลงหนังสือเป็นชั้นวางสำเร็จแล้ว', + 'bookshelf_update' => 'แก้ไขชั้นวาง', + 'bookshelf_update_notification' => 'แก้ไขชั้นวางสำเร็จแล้ว', + 'bookshelf_delete' => 'ลบชั้นวาง', + 'bookshelf_delete_notification' => 'ลบชั้นวางสำเร็จแล้ว', + + // Revisions + 'revision_restore' => 'กู้คืนการแก้ไข', + 'revision_delete' => 'ลบการแก้ไข', + 'revision_delete_notification' => 'ลบการแก้ไขสำเร็จแล้ว', + + // Favourites + 'favourite_add_notification' => 'เพิ่ม ":name" ในรายการโปรดแล้ว', + 'favourite_remove_notification' => 'นำ ":name" ออกจากรายการโปรดแล้ว', + + // Watching + 'watch_update_level_notification' => 'อัปเดตการตั้งค่าการติดตามสำเร็จแล้ว', + + // Auth + 'auth_login' => 'เข้าสู่ระบบ', + 'auth_register' => 'ลงทะเบียนเป็นผู้ใช้ใหม่', + 'auth_password_reset_request' => 'ขอรีเซ็ตรหัสผ่าน', + 'auth_password_reset_update' => 'รีเซ็ตรหัสผ่านแล้ว', + 'mfa_setup_method' => 'ตั้งค่าวิธียืนยันตัวตน MFA', + 'mfa_setup_method_notification' => 'ตั้งค่าการยืนยันตัวตนแบบหลายขั้นตอนสำเร็จแล้ว', + 'mfa_remove_method' => 'ลบวิธียืนยันตัวตน MFA', + 'mfa_remove_method_notification' => 'ลบการยืนยันตัวตนแบบหลายขั้นตอนสำเร็จแล้ว', + + // Settings + 'settings_update' => 'แก้ไขการตั้งค่า', + 'settings_update_notification' => 'แก้ไขการตั้งค่าสำเร็จแล้ว', + 'maintenance_action_run' => 'ดำเนินการบำรุงรักษาระบบ', + + // Webhooks + 'webhook_create' => 'สร้าง Webhook', + 'webhook_create_notification' => 'สร้าง Webhook สำเร็จแล้ว', + 'webhook_update' => 'แก้ไข Webhook', + 'webhook_update_notification' => 'แก้ไข Webhook สำเร็จแล้ว', + 'webhook_delete' => 'ลบ Webhook', + 'webhook_delete_notification' => 'ลบ Webhook สำเร็จแล้ว', + + // Imports + 'import_create' => 'สร้างการนำเข้า', + 'import_create_notification' => 'อัปโหลดไฟล์นำเข้าสำเร็จแล้ว', + 'import_run' => 'ดำเนินการนำเข้า', + 'import_run_notification' => 'นำเข้าเนื้อหาสำเร็จแล้ว', + 'import_delete' => 'ลบการนำเข้า', + 'import_delete_notification' => 'ลบการนำเข้าสำเร็จแล้ว', + + // Users + 'user_create' => 'สร้างผู้ใช้', + 'user_create_notification' => 'สร้างผู้ใช้สำเร็จแล้ว', + 'user_update' => 'แก้ไขผู้ใช้', + 'user_update_notification' => 'แก้ไขผู้ใช้สำเร็จแล้ว', + 'user_delete' => 'ลบผู้ใช้', + 'user_delete_notification' => 'ลบผู้ใช้สำเร็จแล้ว', + + // API Tokens + 'api_token_create' => 'สร้าง API Token', + 'api_token_create_notification' => 'สร้าง API Token สำเร็จแล้ว', + 'api_token_update' => 'แก้ไข API Token', + 'api_token_update_notification' => 'แก้ไข API Token สำเร็จแล้ว', + 'api_token_delete' => 'ลบ API Token', + 'api_token_delete_notification' => 'ลบ API Token สำเร็จแล้ว', + + // Roles + 'role_create' => 'สร้างบทบาท', + 'role_create_notification' => 'สร้างบทบาทสำเร็จแล้ว', + 'role_update' => 'แก้ไขบทบาท', + 'role_update_notification' => 'แก้ไขบทบาทสำเร็จแล้ว', + 'role_delete' => 'ลบบทบาท', + 'role_delete_notification' => 'ลบบทบาทสำเร็จแล้ว', + + // Recycle Bin + 'recycle_bin_empty' => 'ล้างถังรีไซเคิล', + 'recycle_bin_restore' => 'กู้คืนจากถังรีไซเคิล', + 'recycle_bin_destroy' => 'ลบถาวรจากถังรีไซเคิล', + + // Comments + 'commented_on' => 'แสดงความคิดเห็นใน', + 'comment_create' => 'เพิ่มความคิดเห็น', + 'comment_update' => 'แก้ไขความคิดเห็น', + 'comment_delete' => 'ลบความคิดเห็น', + + // Sort Rules + 'sort_rule_create' => 'สร้างกฎการจัดเรียง', + 'sort_rule_create_notification' => 'สร้างกฎการจัดเรียงสำเร็จแล้ว', + 'sort_rule_update' => 'แก้ไขกฎการจัดเรียง', + 'sort_rule_update_notification' => 'แก้ไขกฎการจัดเรียงสำเร็จแล้ว', + 'sort_rule_delete' => 'ลบกฎการจัดเรียง', + 'sort_rule_delete_notification' => 'ลบกฎการจัดเรียงสำเร็จแล้ว', + + // Other + 'permissions_update' => 'แก้ไขสิทธิ์', +]; diff --git a/lang/th/auth.php b/lang/th/auth.php new file mode 100644 index 000000000..c00b29bcd --- /dev/null +++ b/lang/th/auth.php @@ -0,0 +1,117 @@ + 'ข้อมูลประจำตัวไม่ตรงกับที่มีในระบบ', + 'throttle' => 'เข้าสู่ระบบล้มเหลวหลายครั้งเกินไป กรุณาลองใหม่ในอีก :seconds วินาที', + + // Login & Register + 'sign_up' => 'สมัครสมาชิก', + 'log_in' => 'เข้าสู่ระบบ', + 'log_in_with' => 'เข้าสู่ระบบด้วย :socialDriver', + 'sign_up_with' => 'สมัครสมาชิกด้วย :socialDriver', + 'logout' => 'ออกจากระบบ', + + 'name' => 'ชื่อ', + 'username' => 'ชื่อผู้ใช้', + 'email' => 'อีเมล', + 'password' => 'รหัสผ่าน', + 'password_confirm' => 'ยืนยันรหัสผ่าน', + 'password_hint' => 'ต้องมีอย่างน้อย 8 ตัวอักษร', + 'forgot_password' => 'ลืมรหัสผ่าน?', + 'remember_me' => 'จดจำฉัน', + 'ldap_email_hint' => 'กรุณากรอกอีเมลที่จะใช้กับบัญชีนี้', + 'create_account' => 'สร้างบัญชี', + 'already_have_account' => 'มีบัญชีอยู่แล้ว?', + 'dont_have_account' => 'ยังไม่มีบัญชี?', + 'social_login' => 'เข้าสู่ระบบด้วย Social', + 'social_registration' => 'ลงทะเบียนด้วย Social', + 'social_registration_text' => 'ลงทะเบียนและเข้าสู่ระบบด้วยบริการอื่น', + + 'register_thanks' => 'ขอบคุณที่ลงทะเบียน กรุณายืนยันอีเมลเพื่อเข้าสู่ระบบ', + 'register_confirm' => 'ยืนยันและลงทะเบียน', + 'registrations_disabled' => 'ขณะนี้ปิดรับการลงทะเบียน', + 'registration_email_domain_invalid' => 'โดเมนอีเมลนี้ไม่มีสิทธิ์เข้าถึงระบบ', + 'register_success' => 'ขอบคุณที่สมัครสมาชิก คุณได้ลงทะเบียนและเข้าสู่ระบบแล้ว', + + // Login auto-initiation + 'auto_init_starting' => 'กำลังเข้าสู่ระบบ', + 'auto_init_starting_desc' => 'กำลังติดต่อระบบยืนยันตัวตนเพื่อเริ่มกระบวนการเข้าสู่ระบบ หากไม่มีความคืบหน้าภายใน 5 วินาที กรุณาคลิกลิงก์ด้านล่าง', + 'auto_init_start_link' => 'ดำเนินการยืนยันตัวตน', + + // Password Reset + 'reset_password' => 'รีเซ็ตรหัสผ่าน', + 'reset_password_send_instructions' => 'กรอกอีเมลด้านล่าง ระบบจะส่งลิงก์รีเซ็ตรหัสผ่านให้คุณ', + 'reset_password_send_button' => 'ส่งลิงก์รีเซ็ตรหัสผ่าน', + 'reset_password_sent' => 'หากพบอีเมล :email ในระบบ จะมีลิงก์รีเซ็ตรหัสผ่านส่งไปให้', + 'reset_password_success' => 'รีเซ็ตรหัสผ่านสำเร็จแล้ว', + 'email_reset_subject' => 'รีเซ็ตรหัสผ่าน :appName ของคุณ', + 'email_reset_text' => 'คุณได้รับอีเมลนี้เพราะมีการขอรีเซ็ตรหัสผ่านสำหรับบัญชีของคุณ', + 'email_reset_not_requested' => 'หากคุณไม่ได้ขอรีเซ็ตรหัสผ่าน ไม่ต้องดำเนินการใดๆ เพิ่มเติม', + + // Email Confirmation + 'email_confirm_subject' => 'ยืนยันอีเมลของคุณบน :appName', + 'email_confirm_greeting' => 'ขอบคุณที่เข้าร่วม :appName!', + 'email_confirm_text' => 'กรุณายืนยันอีเมลของคุณโดยคลิกปุ่มด้านล่าง:', + 'email_confirm_action' => 'ยืนยันอีเมล', + 'email_confirm_send_error' => 'จำเป็นต้องยืนยันอีเมล แต่ระบบไม่สามารถส่งอีเมลได้ กรุณาติดต่อผู้ดูแลระบบเพื่อตรวจสอบการตั้งค่าอีเมล', + 'email_confirm_success' => 'ยืนยันอีเมลสำเร็จแล้ว สามารถเข้าสู่ระบบได้', + 'email_confirm_resent' => 'ส่งอีเมลยืนยันใหม่แล้ว กรุณาตรวจสอบกล่องจดหมาย', + 'email_confirm_thanks' => 'ขอบคุณที่ยืนยัน!', + 'email_confirm_thanks_desc' => 'กรุณารอสักครู่ขณะที่ระบบดำเนินการยืนยัน หากไม่ถูกเปลี่ยนหน้าภายใน 3 วินาที กรุณาคลิกลิงก์ "ดำเนินการต่อ" ด้านล่าง', + + 'email_not_confirmed' => 'ยังไม่ได้ยืนยันอีเมล', + 'email_not_confirmed_text' => 'อีเมลของคุณยังไม่ได้รับการยืนยัน', + 'email_not_confirmed_click_link' => 'กรุณาคลิกลิงก์ในอีเมลที่ส่งให้คุณหลังจากลงทะเบียน', + 'email_not_confirmed_resend' => 'หากไม่พบอีเมล คุณสามารถส่งอีเมลยืนยันอีกครั้งโดยกรอกแบบฟอร์มด้านล่าง', + 'email_not_confirmed_resend_button' => 'ส่งอีเมลยืนยันอีกครั้ง', + + // User Invite + 'user_invite_email_subject' => 'คุณได้รับเชิญให้เข้าร่วม :appName!', + 'user_invite_email_greeting' => 'มีการสร้างบัญชีให้คุณบน :appName', + 'user_invite_email_text' => 'คลิกปุ่มด้านล่างเพื่อตั้งรหัสผ่านและเข้าใช้งาน:', + 'user_invite_email_action' => 'ตั้งรหัสผ่านบัญชี', + 'user_invite_page_welcome' => 'ยินดีต้อนรับสู่ :appName!', + 'user_invite_page_text' => 'เพื่อเสร็จสิ้นการสร้างบัญชีและเข้าใช้งาน คุณต้องตั้งรหัสผ่านสำหรับเข้าสู่ :appName ในครั้งถัดไป', + 'user_invite_page_confirm_button' => 'ยืนยันรหัสผ่าน', + 'user_invite_success_login' => 'ตั้งรหัสผ่านแล้ว คุณสามารถเข้าสู่ระบบ :appName ด้วยรหัสผ่านที่ตั้งไว้ได้แล้ว!', + + // Multi-factor Authentication + 'mfa_setup' => 'ตั้งค่าการยืนยันตัวตนแบบหลายขั้นตอน', + 'mfa_setup_desc' => 'ตั้งค่า MFA เพื่อเพิ่มความปลอดภัยให้บัญชีของคุณ', + 'mfa_setup_configured' => 'ตั้งค่าแล้ว', + 'mfa_setup_reconfigure' => 'ตั้งค่าใหม่', + 'mfa_setup_remove_confirmation' => 'คุณแน่ใจหรือไม่ว่าต้องการลบวิธียืนยันตัวตนแบบหลายขั้นตอนนี้?', + 'mfa_setup_action' => 'ตั้งค่า', + 'mfa_backup_codes_usage_limit_warning' => 'คุณมีรหัสสำรองเหลือน้อยกว่า 5 รหัส กรุณาสร้างและบันทึกชุดใหม่ก่อนหมด เพื่อป้องกันการถูกล็อกออกจากบัญชี', + 'mfa_option_totp_title' => 'แอป Authenticator', + 'mfa_option_totp_desc' => 'ในการใช้การยืนยันตัวตนแบบหลายขั้นตอน คุณต้องมีแอปพลิเคชันมือถือที่รองรับ TOTP เช่น Google Authenticator, Authy หรือ Microsoft Authenticator', + 'mfa_option_backup_codes_title' => 'รหัสสำรอง', + 'mfa_option_backup_codes_desc' => 'สร้างชุดรหัสสำรองแบบใช้ครั้งเดียว ซึ่งจะใช้กรอกเมื่อเข้าสู่ระบบเพื่อยืนยันตัวตน กรุณาเก็บรักษาไว้ในที่ปลอดภัย', + 'mfa_gen_confirm_and_enable' => 'ยืนยันและเปิดใช้งาน', + 'mfa_gen_backup_codes_title' => 'ตั้งค่ารหัสสำรอง', + 'mfa_gen_backup_codes_desc' => 'บันทึกรายการรหัสด้านล่างไว้ในที่ปลอดภัย เมื่อเข้าระบบคุณสามารถใช้รหัสเหล่านี้เป็นการยืนยันตัวตนขั้นที่สองได้', + 'mfa_gen_backup_codes_download' => 'ดาวน์โหลดรหัส', + 'mfa_gen_backup_codes_usage_warning' => 'รหัสแต่ละรหัสใช้ได้เพียงครั้งเดียว', + 'mfa_gen_totp_title' => 'ตั้งค่าแอปมือถือ', + 'mfa_gen_totp_desc' => 'ในการใช้การยืนยันตัวตนแบบหลายขั้นตอน คุณต้องมีแอปพลิเคชันมือถือที่รองรับ TOTP เช่น Google Authenticator, Authy หรือ Microsoft Authenticator', + 'mfa_gen_totp_scan' => 'สแกน QR code ด้านล่างด้วยแอป Authenticator ที่คุณต้องการใช้', + 'mfa_gen_totp_verify_setup' => 'ยืนยันการตั้งค่า', + 'mfa_gen_totp_verify_setup_desc' => 'ยืนยันว่าทุกอย่างทำงานได้โดยกรอกรหัสที่สร้างจากแอป Authenticator ในช่องด้านล่าง:', + 'mfa_gen_totp_provide_code_here' => 'กรอกรหัสที่สร้างจากแอปของคุณที่นี่', + 'mfa_verify_access' => 'ยืนยันการเข้าถึง', + 'mfa_verify_access_desc' => 'บัญชีของคุณต้องยืนยันตัวตนผ่านการตรวจสอบเพิ่มเติมก่อนเข้าใช้งาน กรุณายืนยันด้วยวิธีที่ตั้งค่าไว้เพื่อดำเนินการต่อ', + 'mfa_verify_no_methods' => 'ยังไม่ได้ตั้งค่าวิธียืนยันตัวตน', + 'mfa_verify_no_methods_desc' => 'ไม่พบวิธียืนยันตัวตนแบบหลายขั้นตอนสำหรับบัญชีของคุณ กรุณาตั้งค่าอย่างน้อยหนึ่งวิธีก่อนเข้าใช้งาน', + 'mfa_verify_use_totp' => 'ยืนยันด้วยแอปมือถือ', + 'mfa_verify_use_backup_codes' => 'ยืนยันด้วยรหัสสำรอง', + 'mfa_verify_backup_code' => 'รหัสสำรอง', + 'mfa_verify_backup_code_desc' => 'กรอกรหัสสำรองที่เหลืออยู่ของคุณด้านล่าง:', + 'mfa_verify_backup_code_enter_here' => 'กรอกรหัสสำรองที่นี่', + 'mfa_verify_totp_desc' => 'กรอกรหัสที่สร้างจากแอปมือถือของคุณด้านล่าง:', + 'mfa_setup_login_notification' => 'ตั้งค่าวิธียืนยันตัวตนแล้ว กรุณาเข้าสู่ระบบอีกครั้งด้วยวิธีที่ตั้งค่าไว้', +]; diff --git a/lang/th/common.php b/lang/th/common.php new file mode 100644 index 000000000..9a8b4fcc5 --- /dev/null +++ b/lang/th/common.php @@ -0,0 +1,115 @@ + 'ยกเลิก', + 'close' => 'ปิด', + 'confirm' => 'ยืนยัน', + 'back' => 'ย้อนกลับ', + 'save' => 'บันทึก', + 'continue' => 'ดำเนินการต่อ', + 'select' => 'เลือก', + 'toggle_all' => 'สลับทั้งหมด', + 'more' => 'เพิ่มเติม', + + // Form Labels + 'name' => 'ชื่อ', + 'description' => 'คำอธิบาย', + 'role' => 'บทบาท', + 'cover_image' => 'ภาพปก', + 'cover_image_description' => 'รูปภาพนี้ควรมีขนาดประมาณ 440x250px แต่จะถูกปรับขนาดและตัดให้เหมาะกับการแสดงผลในสถานการณ์ต่างๆ ดังนั้นขนาดที่แสดงจริงอาจแตกต่างกัน', + + // Actions + 'actions' => 'การดำเนินการ', + 'view' => 'ดู', + 'view_all' => 'ดูทั้งหมด', + 'new' => 'ใหม่', + 'create' => 'สร้าง', + 'update' => 'อัปเดต', + 'edit' => 'แก้ไข', + 'archive' => 'เก็บถาวร', + 'unarchive' => 'ยกเลิกการเก็บถาวร', + 'sort' => 'จัดเรียง', + 'move' => 'ย้าย', + 'copy' => 'คัดลอก', + 'reply' => 'ตอบกลับ', + 'delete' => 'ลบ', + 'delete_confirm' => 'ยืนยันการลบ', + 'search' => 'ค้นหา', + 'search_clear' => 'ล้างการค้นหา', + 'reset' => 'รีเซ็ต', + 'remove' => 'ลบออก', + 'add' => 'เพิ่ม', + 'configure' => 'กำหนดค่า', + 'manage' => 'จัดการ', + 'fullscreen' => 'เต็มหน้าจอ', + 'favourite' => 'เพิ่มในรายการโปรด', + 'unfavourite' => 'นำออกจากรายการโปรด', + 'next' => 'ถัดไป', + 'previous' => 'ก่อนหน้า', + 'filter_active' => 'ตัวกรองที่ใช้งานอยู่:', + 'filter_clear' => 'ล้างตัวกรอง', + 'download' => 'ดาวน์โหลด', + 'open_in_tab' => 'เปิดในแท็บใหม่', + 'open' => 'เปิด', + + // Sort Options + 'sort_options' => 'ตัวเลือกการจัดเรียง', + 'sort_direction_toggle' => 'สลับทิศทางการจัดเรียง', + 'sort_ascending' => 'จัดเรียงจากน้อยไปมาก', + 'sort_descending' => 'จัดเรียงจากมากไปน้อย', + 'sort_name' => 'ชื่อ', + 'sort_default' => 'ค่าเริ่มต้น', + 'sort_created_at' => 'วันที่สร้าง', + 'sort_updated_at' => 'วันที่แก้ไขล่าสุด', + + // Misc + 'deleted_user' => 'ผู้ใช้ที่ถูกลบ', + 'no_activity' => 'ไม่มีกิจกรรมที่จะแสดง', + 'no_items' => 'ไม่มีรายการ', + 'back_to_top' => 'กลับไปด้านบน', + 'skip_to_main_content' => 'ข้ามไปยังเนื้อหาหลัก', + 'toggle_details' => 'แสดง/ซ่อนรายละเอียด', + 'toggle_thumbnails' => 'แสดง/ซ่อนภาพย่อ', + 'details' => 'รายละเอียด', + 'grid_view' => 'มุมมองตาราง', + 'list_view' => 'มุมมองรายการ', + 'default' => 'ค่าเริ่มต้น', + 'breadcrumb' => 'เส้นทางนำทาง', + 'status' => 'สถานะ', + 'status_active' => 'ใช้งาน', + 'status_inactive' => 'ไม่ใช้งาน', + 'never' => 'ไม่เคย', + 'none' => 'ไม่มี', + + // Header + 'homepage' => 'หน้าแรก', + 'header_menu_expand' => 'ขยายเมนูส่วนหัว', + 'profile_menu' => 'เมนูโปรไฟล์', + 'view_profile' => 'ดูโปรไฟล์', + 'edit_profile' => 'แก้ไขโปรไฟล์', + 'dark_mode' => 'โหมดมืด', + 'light_mode' => 'โหมดสว่าง', + 'global_search' => 'ค้นหาทั้งระบบ', + + // Layout tabs + 'tab_info' => 'ข้อมูล', + 'tab_info_label' => 'แท็บ: แสดงข้อมูลเพิ่มเติม', + 'tab_content' => 'เนื้อหา', + 'tab_content_label' => 'แท็บ: แสดงเนื้อหาหลัก', + + // Email Content + 'email_action_help' => 'หากไม่สามารถคลิกปุ่ม ":actionText" ได้ กรุณาคัดลอก URL ด้านล่างและวางในเบราว์เซอร์:', + 'email_rights' => 'สงวนลิขสิทธิ์', + + // Footer Link Options + // Not directly used but available for convenience to users. + 'privacy_policy' => 'นโยบายความเป็นส่วนตัว', + 'terms_of_service' => 'ข้อกำหนดการใช้งาน', + + // OpenSearch + 'opensearch_description' => 'ค้นหา :appName', +]; diff --git a/lang/th/components.php b/lang/th/components.php new file mode 100644 index 000000000..2931d2bc7 --- /dev/null +++ b/lang/th/components.php @@ -0,0 +1,46 @@ + 'เลือกรูปภาพ', + 'image_list' => 'รายการรูปภาพ', + 'image_details' => 'รายละเอียดรูปภาพ', + 'image_upload' => 'อัปโหลดรูปภาพ', + 'image_intro' => 'คุณสามารถเลือกและจัดการรูปภาพที่เคยอัปโหลดไว้ในระบบได้ที่นี่', + 'image_intro_upload' => 'อัปโหลดรูปภาพใหม่โดยลากไฟล์รูปภาพมาวางในหน้าต่างนี้ หรือใช้ปุ่ม "อัปโหลดรูปภาพ" ด้านบน', + 'image_all' => 'ทั้งหมด', + 'image_all_title' => 'ดูรูปภาพทั้งหมด', + 'image_book_title' => 'ดูรูปภาพที่อัปโหลดในหนังสือนี้', + 'image_page_title' => 'ดูรูปภาพที่อัปโหลดในหน้านี้', + 'image_search_hint' => 'ค้นหาตามชื่อรูปภาพ', + 'image_uploaded' => 'อัปโหลดเมื่อ :uploadedDate', + 'image_uploaded_by' => 'อัปโหลดโดย :userName', + 'image_uploaded_to' => 'อัปโหลดไปยัง :pageLink', + 'image_updated' => 'อัปเดตเมื่อ :updateDate', + 'image_load_more' => 'โหลดเพิ่มเติม', + 'image_image_name' => 'ชื่อรูปภาพ', + 'image_delete_used' => 'รูปภาพนี้ถูกใช้งานในหน้าด้านล่าง', + 'image_delete_confirm_text' => 'คุณแน่ใจหรือไม่ว่าต้องการลบรูปภาพนี้?', + 'image_select_image' => 'เลือกรูปภาพ', + 'image_dropzone' => 'วางรูปภาพหรือคลิกที่นี่เพื่ออัปโหลด', + 'image_dropzone_drop' => 'วางรูปภาพที่นี่เพื่ออัปโหลด', + 'images_deleted' => 'ลบรูปภาพแล้ว', + 'image_preview' => 'ดูตัวอย่างรูปภาพ', + 'image_upload_success' => 'อัปโหลดรูปภาพสำเร็จแล้ว', + 'image_update_success' => 'อัปเดตรายละเอียดรูปภาพสำเร็จแล้ว', + 'image_delete_success' => 'ลบรูปภาพสำเร็จแล้ว', + 'image_replace' => 'แทนที่รูปภาพ', + 'image_replace_success' => 'อัปเดตไฟล์รูปภาพสำเร็จแล้ว', + 'image_rebuild_thumbs' => 'สร้างภาพย่อขนาดต่างๆ ใหม่', + 'image_rebuild_thumbs_success' => 'สร้างภาพย่อขนาดต่างๆ ใหม่สำเร็จแล้ว!', + + // Code Editor + 'code_editor' => 'แก้ไขโค้ด', + 'code_language' => 'ภาษาโค้ด', + 'code_content' => 'เนื้อหาโค้ด', + 'code_session_history' => 'ประวัติเซสชัน', + 'code_save' => 'บันทึกโค้ด', +]; diff --git a/lang/th/editor.php b/lang/th/editor.php new file mode 100644 index 000000000..4af059bf1 --- /dev/null +++ b/lang/th/editor.php @@ -0,0 +1,182 @@ + 'ทั่วไป', + 'advanced' => 'ขั้นสูง', + 'none' => 'ไม่มี', + 'cancel' => 'ยกเลิก', + 'save' => 'บันทึก', + 'close' => 'ปิด', + 'apply' => 'ใช้งาน', + 'undo' => 'เลิกทำ', + 'redo' => 'ทำซ้ำ', + 'left' => 'ซ้าย', + 'center' => 'กลาง', + 'right' => 'ขวา', + 'top' => 'บน', + 'middle' => 'กลาง', + 'bottom' => 'ล่าง', + 'width' => 'ความกว้าง', + 'height' => 'ความสูง', + 'More' => 'เพิ่มเติม', + 'select' => 'เลือก...', + + // Toolbar + 'formats' => 'รูปแบบ', + 'header_large' => 'หัวข้อใหญ่', + 'header_medium' => 'หัวข้อกลาง', + 'header_small' => 'หัวข้อเล็ก', + 'header_tiny' => 'หัวข้อเล็กมาก', + 'paragraph' => 'ย่อหน้า', + 'blockquote' => 'คำพูดอ้างอิง', + 'inline_code' => 'โค้ดแบบอินไลน์', + 'callouts' => 'กล่องข้อความเน้น', + 'callout_information' => 'ข้อมูล', + 'callout_success' => 'สำเร็จ', + 'callout_warning' => 'คำเตือน', + 'callout_danger' => 'อันตราย', + 'bold' => 'ตัวหนา', + 'italic' => 'ตัวเอียง', + 'underline' => 'ขีดเส้นใต้', + 'strikethrough' => 'ขีดทับ', + 'superscript' => 'ตัวยก', + 'subscript' => 'ตัวห้อย', + 'text_color' => 'สีตัวอักษร', + 'highlight_color' => 'สีไฮไลต์', + 'custom_color' => 'สีกำหนดเอง', + 'remove_color' => 'ลบสี', + 'background_color' => 'สีพื้นหลัง', + 'align_left' => 'จัดชิดซ้าย', + 'align_center' => 'จัดกึ่งกลาง', + 'align_right' => 'จัดชิดขวา', + 'align_justify' => 'จัดเต็มบรรทัด', + 'list_bullet' => 'รายการแบบจุด', + 'list_numbered' => 'รายการแบบตัวเลข', + 'list_task' => 'รายการงาน', + 'indent_increase' => 'เพิ่มการย่อหน้า', + 'indent_decrease' => 'ลดการย่อหน้า', + 'table' => 'ตาราง', + 'insert_image' => 'แทรกรูปภาพ', + 'insert_image_title' => 'แทรก/แก้ไขรูปภาพ', + 'insert_link' => 'แทรก/แก้ไขลิงก์', + 'insert_link_title' => 'แทรก/แก้ไขลิงก์', + 'insert_horizontal_line' => 'แทรกเส้นแนวนอน', + 'insert_code_block' => 'แทรกบล็อกโค้ด', + 'edit_code_block' => 'แก้ไขบล็อกโค้ด', + 'insert_drawing' => 'แทรก/แก้ไขภาพวาด', + 'drawing_manager' => 'ตัวจัดการภาพวาด', + 'insert_media' => 'แทรก/แก้ไขสื่อ', + 'insert_media_title' => 'แทรก/แก้ไขสื่อ', + 'clear_formatting' => 'ล้างการจัดรูปแบบ', + 'source_code' => 'ซอร์สโค้ด', + 'source_code_title' => 'ซอร์สโค้ด', + 'fullscreen' => 'เต็มหน้าจอ', + 'image_options' => 'ตัวเลือกรูปภาพ', + + // Tables + 'table_properties' => 'คุณสมบัติตาราง', + 'table_properties_title' => 'คุณสมบัติตาราง', + 'delete_table' => 'ลบตาราง', + 'table_clear_formatting' => 'ล้างการจัดรูปแบบตาราง', + 'resize_to_contents' => 'ปรับขนาดตามเนื้อหา', + 'row_header' => 'แถวหัวตาราง', + 'insert_row_before' => 'แทรกแถวก่อนหน้า', + 'insert_row_after' => 'แทรกแถวถัดไป', + 'delete_row' => 'ลบแถว', + 'insert_column_before' => 'แทรกคอลัมน์ก่อนหน้า', + 'insert_column_after' => 'แทรกคอลัมน์ถัดไป', + 'delete_column' => 'ลบคอลัมน์', + 'table_cell' => 'เซลล์', + 'table_row' => 'แถว', + 'table_column' => 'คอลัมน์', + 'cell_properties' => 'คุณสมบัติเซลล์', + 'cell_properties_title' => 'คุณสมบัติเซลล์', + 'cell_type' => 'ประเภทเซลล์', + 'cell_type_cell' => 'เซลล์', + 'cell_scope' => 'ขอบเขต', + 'cell_type_header' => 'เซลล์หัวตาราง', + 'merge_cells' => 'รวมเซลล์', + 'split_cell' => 'แยกเซลล์', + 'table_row_group' => 'กลุ่มแถว', + 'table_column_group' => 'กลุ่มคอลัมน์', + 'horizontal_align' => 'การจัดแนวนอน', + 'vertical_align' => 'การจัดแนวตั้ง', + 'border_width' => 'ความกว้างเส้นขอบ', + 'border_style' => 'รูปแบบเส้นขอบ', + 'border_color' => 'สีเส้นขอบ', + 'row_properties' => 'คุณสมบัติแถว', + 'row_properties_title' => 'คุณสมบัติแถว', + 'cut_row' => 'ตัดแถว', + 'copy_row' => 'คัดลอกแถว', + 'paste_row_before' => 'วางแถวก่อนหน้า', + 'paste_row_after' => 'วางแถวถัดไป', + 'row_type' => 'ประเภทแถว', + 'row_type_header' => 'ส่วนหัว', + 'row_type_body' => 'ส่วนเนื้อหา', + 'row_type_footer' => 'ส่วนท้าย', + 'alignment' => 'การจัดตำแหน่ง', + 'cut_column' => 'ตัดคอลัมน์', + 'copy_column' => 'คัดลอกคอลัมน์', + 'paste_column_before' => 'วางคอลัมน์ก่อนหน้า', + 'paste_column_after' => 'วางคอลัมน์ถัดไป', + 'cell_padding' => 'ระยะห่างภายในเซลล์', + 'cell_spacing' => 'ระยะห่างระหว่างเซลล์', + 'caption' => 'คำบรรยาย', + 'show_caption' => 'แสดงคำบรรยาย', + 'constrain' => 'รักษาสัดส่วน', + 'cell_border_solid' => 'เส้นทึบ', + 'cell_border_dotted' => 'เส้นจุด', + 'cell_border_dashed' => 'เส้นประ', + 'cell_border_double' => 'เส้นคู่', + 'cell_border_groove' => 'เส้นร่อง', + 'cell_border_ridge' => 'เส้นนูน', + 'cell_border_inset' => 'เส้นฝัง', + 'cell_border_outset' => 'เส้นนูนออก', + 'cell_border_none' => 'ไม่มี', + 'cell_border_hidden' => 'ซ่อน', + + // Images, links, details/summary & embed + 'source' => 'แหล่งที่มา', + 'alt_desc' => 'คำอธิบายทดแทน', + 'embed' => 'ฝังเนื้อหา', + 'paste_embed' => 'วางโค้ดฝังเนื้อหาด้านล่าง:', + 'url' => 'URL', + 'text_to_display' => 'ข้อความที่แสดง', + 'title' => 'ชื่อเรื่อง', + 'browse_links' => 'เรียกดูลิงก์', + 'open_link' => 'เปิดลิงก์', + 'open_link_in' => 'เปิดลิงก์ใน...', + 'open_link_current' => 'หน้าต่างปัจจุบัน', + 'open_link_new' => 'หน้าต่างใหม่', + 'remove_link' => 'ลบลิงก์', + 'insert_collapsible' => 'แทรกบล็อกที่ย่อได้', + 'collapsible_unwrap' => 'ยกเลิกการห่อ', + 'edit_label' => 'แก้ไขป้ายกำกับ', + 'toggle_open_closed' => 'สลับเปิด/ปิด', + 'collapsible_edit' => 'แก้ไขบล็อกที่ย่อได้', + 'toggle_label' => 'ป้ายกำกับสลับ', + + // About view + 'about' => 'เกี่ยวกับตัวแก้ไข', + 'about_title' => 'เกี่ยวกับตัวแก้ไข WYSIWYG', + 'editor_license' => 'สัญญาอนุญาตและลิขสิทธิ์ตัวแก้ไข', + 'editor_lexical_license' => 'ตัวแก้ไขนี้สร้างจาก :lexicalLink ซึ่งเผยแพร่ภายใต้สัญญาอนุญาต MIT', + 'editor_lexical_license_link' => 'ดูรายละเอียดสัญญาอนุญาตฉบับเต็มได้ที่นี่', + 'editor_tiny_license' => 'ตัวแก้ไขนี้สร้างด้วย :tinyLink ซึ่งเผยแพร่ภายใต้สัญญาอนุญาต MIT', + 'editor_tiny_license_link' => 'ดูรายละเอียดลิขสิทธิ์และสัญญาอนุญาตของ TinyMCE ได้ที่นี่', + 'save_continue' => 'บันทึกหน้าและดำเนินการต่อ', + 'callouts_cycle' => '(กดต่อเนื่องเพื่อสลับประเภท)', + 'link_selector' => 'ลิงก์ไปยังเนื้อหา', + 'shortcuts' => 'แป้นพิมพ์ลัด', + 'shortcut' => 'แป้นพิมพ์ลัด', + 'shortcuts_intro' => 'แป้นพิมพ์ลัดต่อไปนี้ใช้งานได้ในตัวแก้ไข:', + 'windows_linux' => '(Windows/Linux)', + 'mac' => '(Mac)', + 'description' => 'คำอธิบาย', +]; diff --git a/lang/th/entities.php b/lang/th/entities.php new file mode 100644 index 000000000..1f0f30ec6 --- /dev/null +++ b/lang/th/entities.php @@ -0,0 +1,477 @@ + 'สร้างล่าสุด', + 'recently_created_pages' => 'หน้าที่สร้างล่าสุด', + 'recently_updated_pages' => 'หน้าที่แก้ไขล่าสุด', + 'recently_created_chapters' => 'บทที่สร้างล่าสุด', + 'recently_created_books' => 'หนังสือที่สร้างล่าสุด', + 'recently_created_shelves' => 'ชั้นวางที่สร้างล่าสุด', + 'recently_update' => 'แก้ไขล่าสุด', + 'recently_viewed' => 'ดูล่าสุด', + 'recent_activity' => 'กิจกรรมล่าสุด', + 'create_now' => 'สร้างตอนนี้', + 'revisions' => 'การแก้ไข', + 'meta_revision' => 'การแก้ไข #:revisionCount', + 'meta_created' => 'สร้างเมื่อ :timeLength', + 'meta_created_name' => 'สร้างเมื่อ :timeLength โดย :user', + 'meta_updated' => 'แก้ไขเมื่อ :timeLength', + 'meta_updated_name' => 'แก้ไขเมื่อ :timeLength โดย :user', + 'meta_owned_name' => 'เป็นของ :user', + 'meta_reference_count' => 'อ้างอิงโดย :count รายการ', + 'entity_select' => 'เลือกรายการ', + 'entity_select_lack_permission' => 'คุณไม่มีสิทธิ์เลือกรายการนี้', + 'images' => 'รูปภาพ', + 'my_recent_drafts' => 'ร่างล่าสุดของฉัน', + 'my_recently_viewed' => 'ที่ฉันดูล่าสุด', + 'my_most_viewed_favourites' => 'รายการโปรดที่ดูบ่อยที่สุด', + 'my_favourites' => 'รายการโปรดของฉัน', + 'no_pages_viewed' => 'คุณยังไม่ได้ดูหน้าใด', + 'no_pages_recently_created' => 'ยังไม่มีหน้าที่สร้างล่าสุด', + 'no_pages_recently_updated' => 'ยังไม่มีหน้าที่แก้ไขล่าสุด', + 'export' => 'ส่งออก', + 'export_html' => 'ไฟล์เว็บ (HTML)', + 'export_pdf' => 'ไฟล์ PDF', + 'export_text' => 'ไฟล์ข้อความธรรมดา', + 'export_md' => 'ไฟล์ Markdown', + 'export_zip' => 'ZIP แบบพกพา', + 'default_template' => 'แม่แบบหน้าเริ่มต้น', + 'default_template_explain' => 'กำหนดแม่แบบหน้าที่จะใช้เป็นเนื้อหาเริ่มต้นสำหรับหน้าที่สร้างในรายการนี้ โปรดทราบว่าจะใช้งานได้เฉพาะเมื่อผู้สร้างหน้ามีสิทธิ์ดูแม่แบบที่เลือก', + 'default_template_select' => 'เลือกแม่แบบหน้า', + 'import' => 'นำเข้า', + 'import_validate' => 'ตรวจสอบการนำเข้า', + 'import_desc' => 'นำเข้าหนังสือ บท และหน้าจากไฟล์ ZIP แบบพกพาจากระบบเดียวกันหรือต่างระบบ เลือกไฟล์ ZIP เพื่อดำเนินการต่อ หลังจากอัปโหลดและตรวจสอบแล้ว คุณจะสามารถกำหนดค่าและยืนยันการนำเข้าในขั้นตอนถัดไป', + 'import_zip_select' => 'เลือกไฟล์ ZIP ที่จะอัปโหลด', + 'import_zip_validation_errors' => 'พบข้อผิดพลาดขณะตรวจสอบไฟล์ ZIP:', + 'import_pending' => 'การนำเข้าที่รอดำเนินการ', + 'import_pending_none' => 'ยังไม่มีการนำเข้าที่เริ่มไว้', + 'import_continue' => 'ดำเนินการนำเข้าต่อ', + 'import_continue_desc' => 'ตรวจสอบเนื้อหาที่จะนำเข้าจากไฟล์ ZIP เมื่อพร้อมแล้ว ให้รันการนำเข้าเพื่อเพิ่มเนื้อหาเข้าสู่ระบบ ไฟล์ ZIP จะถูกลบโดยอัตโนมัติเมื่อนำเข้าสำเร็จ', + 'import_details' => 'รายละเอียดการนำเข้า', + 'import_run' => 'รันการนำเข้า', + 'import_size' => 'ขนาดไฟล์ ZIP :size', + 'import_uploaded_at' => 'อัปโหลดเมื่อ :relativeTime', + 'import_uploaded_by' => 'อัปโหลดโดย', + 'import_location' => 'ตำแหน่งนำเข้า', + 'import_location_desc' => 'เลือกตำแหน่งปลายทางสำหรับเนื้อหาที่นำเข้า คุณต้องมีสิทธิ์สร้างเนื้อหาในตำแหน่งที่เลือก', + 'import_delete_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการลบการนำเข้านี้?', + 'import_delete_desc' => 'การดำเนินการนี้จะลบไฟล์ ZIP ที่อัปโหลดไว้และไม่สามารถยกเลิกได้', + 'import_errors' => 'ข้อผิดพลาดในการนำเข้า', + 'import_errors_desc' => 'เกิดข้อผิดพลาดต่อไปนี้ระหว่างการนำเข้า:', + 'breadcrumb_siblings_for_page' => 'นำทางไปยังหน้าที่อยู่ระดับเดียวกัน', + 'breadcrumb_siblings_for_chapter' => 'นำทางไปยังบทที่อยู่ระดับเดียวกัน', + 'breadcrumb_siblings_for_book' => 'นำทางไปยังหนังสือที่อยู่ระดับเดียวกัน', + 'breadcrumb_siblings_for_bookshelf' => 'นำทางไปยังชั้นวางที่อยู่ระดับเดียวกัน', + + // Permissions and restrictions + 'permissions' => 'สิทธิ์', + 'permissions_desc' => 'ตั้งค่าสิทธิ์ที่นี่เพื่อแทนที่สิทธิ์เริ่มต้นที่กำหนดโดยบทบาทผู้ใช้', + 'permissions_book_cascade' => 'สิทธิ์ที่กำหนดบนหนังสือจะส่งต่อไปยังบทและหน้าลูกโดยอัตโนมัติ เว้นแต่มีการกำหนดสิทธิ์ของตัวเองไว้', + 'permissions_chapter_cascade' => 'สิทธิ์ที่กำหนดบนบทจะส่งต่อไปยังหน้าลูกโดยอัตโนมัติ เว้นแต่มีการกำหนดสิทธิ์ของตัวเองไว้', + 'permissions_save' => 'บันทึกสิทธิ์', + 'permissions_owner' => 'เจ้าของ', + 'permissions_role_everyone_else' => 'ทุกคนที่เหลือ', + 'permissions_role_everyone_else_desc' => 'ตั้งค่าสิทธิ์สำหรับบทบาทที่ไม่ได้กำหนดไว้โดยเฉพาะ', + 'permissions_role_override' => 'แทนที่สิทธิ์สำหรับบทบาท', + 'permissions_inherit_defaults' => 'รับสิทธิ์เริ่มต้น', + + // Search + 'search_results' => 'ผลการค้นหา', + 'search_total_results_found' => 'พบ :count ผลลัพธ์', + 'search_clear' => 'ล้างการค้นหา', + 'search_no_pages' => 'ไม่มีหน้าที่ตรงกับการค้นหานี้', + 'search_for_term' => 'ค้นหา :term', + 'search_more' => 'ผลลัพธ์เพิ่มเติม', + 'search_advanced' => 'ค้นหาขั้นสูง', + 'search_terms' => 'คำค้นหา', + 'search_content_type' => 'ประเภทเนื้อหา', + 'search_exact_matches' => 'ตรงทั้งหมด', + 'search_tags' => 'ค้นหาด้วยแท็ก', + 'search_options' => 'ตัวเลือก', + 'search_viewed_by_me' => 'ที่ฉันดูแล้ว', + 'search_not_viewed_by_me' => 'ที่ฉันยังไม่ได้ดู', + 'search_permissions_set' => 'มีการกำหนดสิทธิ์', + 'search_created_by_me' => 'ที่ฉันสร้าง', + 'search_updated_by_me' => 'ที่ฉันแก้ไข', + 'search_owned_by_me' => 'ที่ฉันเป็นเจ้าของ', + 'search_date_options' => 'ตัวเลือกวันที่', + 'search_updated_before' => 'แก้ไขก่อน', + 'search_updated_after' => 'แก้ไขหลัง', + 'search_created_before' => 'สร้างก่อน', + 'search_created_after' => 'สร้างหลัง', + 'search_set_date' => 'กำหนดวันที่', + 'search_update' => 'อัปเดตการค้นหา', + + // Shelves + 'shelf' => 'ชั้นวาง', + 'shelves' => 'ชั้นวาง', + 'x_shelves' => ':count ชั้นวาง', + 'shelves_empty' => 'ยังไม่มีชั้นวาง', + 'shelves_create' => 'สร้างชั้นวางใหม่', + 'shelves_popular' => 'ชั้นวางยอดนิยม', + 'shelves_new' => 'ชั้นวางใหม่', + 'shelves_new_action' => 'ชั้นวางใหม่', + 'shelves_popular_empty' => 'ชั้นวางที่ได้รับความนิยมมากที่สุดจะแสดงที่นี่', + 'shelves_new_empty' => 'ชั้นวางที่สร้างล่าสุดจะแสดงที่นี่', + 'shelves_save' => 'บันทึกชั้นวาง', + 'shelves_books' => 'หนังสือในชั้นวางนี้', + 'shelves_add_books' => 'เพิ่มหนังสือในชั้นวางนี้', + 'shelves_drag_books' => 'ลากหนังสือด้านล่างเพื่อเพิ่มในชั้นวางนี้', + 'shelves_empty_contents' => 'ชั้นวางนี้ยังไม่มีหนังสือ', + 'shelves_edit_and_assign' => 'แก้ไขชั้นวางเพื่อกำหนดหนังสือ', + 'shelves_edit_named' => 'แก้ไขชั้นวาง :name', + 'shelves_edit' => 'แก้ไขชั้นวาง', + 'shelves_delete' => 'ลบชั้นวาง', + 'shelves_delete_named' => 'ลบชั้นวาง :name', + 'shelves_delete_explain' => "การดำเนินการนี้จะลบชั้นวางชื่อ ':name' หนังสือภายในจะไม่ถูกลบ", + 'shelves_delete_confirmation' => 'คุณแน่ใจหรือไม่ว่าต้องการลบชั้นวางนี้?', + 'shelves_permissions' => 'สิทธิ์ชั้นวาง', + 'shelves_permissions_updated' => 'อัปเดตสิทธิ์ชั้นวางแล้ว', + 'shelves_permissions_active' => 'สิทธิ์ชั้นวางเปิดใช้งานอยู่', + 'shelves_permissions_cascade_warning' => 'สิทธิ์บนชั้นวางไม่ส่งต่อไปยังหนังสือภายในโดยอัตโนมัติ เนื่องจากหนังสือสามารถอยู่ในหลายชั้นวางได้ อย่างไรก็ตาม สามารถคัดลอกสิทธิ์ไปยังหนังสือลูกได้โดยใช้ตัวเลือกด้านล่าง', + 'shelves_permissions_create' => 'สิทธิ์สร้างชั้นวางจะใช้สำหรับการคัดลอกสิทธิ์ไปยังหนังสือลูกเท่านั้น ไม่ได้ควบคุมความสามารถในการสร้างหนังสือ', + 'shelves_copy_permissions_to_books' => 'คัดลอกสิทธิ์ไปยังหนังสือ', + 'shelves_copy_permissions' => 'คัดลอกสิทธิ์', + 'shelves_copy_permissions_explain' => 'การดำเนินการนี้จะนำการตั้งค่าสิทธิ์ปัจจุบันของชั้นวางนี้ไปใช้กับหนังสือทั้งหมดภายใน ก่อนเริ่มต้น ให้ตรวจสอบว่าบันทึกการเปลี่ยนแปลงสิทธิ์แล้ว', + 'shelves_copy_permission_success' => 'คัดลอกสิทธิ์ชั้นวางไปยัง :count หนังสือแล้ว', + + // Books + 'book' => 'หนังสือ', + 'books' => 'หนังสือ', + 'x_books' => ':count หนังสือ', + 'books_empty' => 'ยังไม่มีหนังสือ', + 'books_popular' => 'หนังสือยอดนิยม', + 'books_recent' => 'หนังสือล่าสุด', + 'books_new' => 'หนังสือใหม่', + 'books_new_action' => 'หนังสือใหม่', + 'books_popular_empty' => 'หนังสือที่ได้รับความนิยมมากที่สุดจะแสดงที่นี่', + 'books_new_empty' => 'หนังสือที่สร้างล่าสุดจะแสดงที่นี่', + 'books_create' => 'สร้างหนังสือใหม่', + 'books_delete' => 'ลบหนังสือ', + 'books_delete_named' => 'ลบหนังสือ :bookName', + 'books_delete_explain' => 'การดำเนินการนี้จะลบหนังสือชื่อ \':bookName\' หน้าและบทภายในทั้งหมดจะถูกลบด้วย', + 'books_delete_confirmation' => 'คุณแน่ใจหรือไม่ว่าต้องการลบหนังสือนี้?', + 'books_edit' => 'แก้ไขหนังสือ', + 'books_edit_named' => 'แก้ไขหนังสือ :bookName', + 'books_form_book_name' => 'ชื่อหนังสือ', + 'books_save' => 'บันทึกหนังสือ', + 'books_permissions' => 'สิทธิ์หนังสือ', + 'books_permissions_updated' => 'อัปเดตสิทธิ์หนังสือแล้ว', + 'books_empty_contents' => 'ยังไม่มีหน้าหรือบทในหนังสือนี้', + 'books_empty_create_page' => 'สร้างหน้าใหม่', + 'books_empty_sort_current_book' => 'จัดเรียงหนังสือนี้', + 'books_empty_add_chapter' => 'เพิ่มบท', + 'books_permissions_active' => 'สิทธิ์หนังสือเปิดใช้งานอยู่', + 'books_search_this' => 'ค้นหาในหนังสือนี้', + 'books_navigation' => 'การนำทางหนังสือ', + 'books_sort' => 'จัดเรียงเนื้อหาหนังสือ', + 'books_sort_desc' => 'ย้ายบทและหน้าภายในหนังสือเพื่อจัดเรียงใหม่ สามารถเพิ่มหนังสือเล่มอื่นเพื่อย้ายบทและหน้าระหว่างหนังสือได้ อาจตั้งกฎการจัดเรียงอัตโนมัติเพื่อจัดเรียงเนื้อหาโดยอัตโนมัติเมื่อมีการเปลี่ยนแปลง', + 'books_sort_auto_sort' => 'ตัวเลือกการจัดเรียงอัตโนมัติ', + 'books_sort_auto_sort_active' => 'การจัดเรียงอัตโนมัติเปิดใช้งาน: :sortName', + 'books_sort_auto_sort_creation_hint' => 'กฎการจัดเรียงอัตโนมัติสามารถสร้างได้ในหน้าตั้งค่า "รายการและการจัดเรียง" โดยผู้ใช้ที่มีสิทธิ์ที่เกี่ยวข้อง', + 'books_sort_named' => 'จัดเรียงหนังสือ :bookName', + 'books_sort_name' => 'จัดเรียงตามชื่อ', + 'books_sort_created' => 'จัดเรียงตามวันที่สร้าง', + 'books_sort_updated' => 'จัดเรียงตามวันที่แก้ไข', + 'books_sort_chapters_first' => 'บทอยู่ก่อน', + 'books_sort_chapters_last' => 'บทอยู่หลัง', + 'books_sort_show_other' => 'แสดงหนังสือเล่มอื่น', + 'books_sort_save' => 'บันทึกลำดับใหม่', + 'books_sort_show_other_desc' => 'เพิ่มหนังสือเล่มอื่นที่นี่เพื่อรวมในการจัดเรียง และอนุญาตให้จัดระเบียบข้ามหนังสือได้ง่าย', + 'books_sort_move_up' => 'เลื่อนขึ้น', + 'books_sort_move_down' => 'เลื่อนลง', + 'books_sort_move_prev_book' => 'ย้ายไปหนังสือก่อนหน้า', + 'books_sort_move_next_book' => 'ย้ายไปหนังสือถัดไป', + 'books_sort_move_prev_chapter' => 'ย้ายเข้าบทก่อนหน้า', + 'books_sort_move_next_chapter' => 'ย้ายเข้าบทถัดไป', + 'books_sort_move_book_start' => 'ย้ายไปต้นหนังสือ', + 'books_sort_move_book_end' => 'ย้ายไปท้ายหนังสือ', + 'books_sort_move_before_chapter' => 'ย้ายไปก่อนบท', + 'books_sort_move_after_chapter' => 'ย้ายไปหลังบท', + 'books_copy' => 'คัดลอกหนังสือ', + 'books_copy_success' => 'คัดลอกหนังสือสำเร็จแล้ว', + + // Chapters + 'chapter' => 'บท', + 'chapters' => 'บท', + 'x_chapters' => ':count บท', + 'chapters_popular' => 'บทยอดนิยม', + 'chapters_new' => 'บทใหม่', + 'chapters_create' => 'สร้างบทใหม่', + 'chapters_delete' => 'ลบบท', + 'chapters_delete_named' => 'ลบบท :chapterName', + 'chapters_delete_explain' => 'การดำเนินการนี้จะลบบทชื่อ \':chapterName\' หน้าทั้งหมดในบทนี้จะถูกลบด้วย', + 'chapters_delete_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการลบบทนี้?', + 'chapters_edit' => 'แก้ไขบท', + 'chapters_edit_named' => 'แก้ไขบท :chapterName', + 'chapters_save' => 'บันทึกบท', + 'chapters_move' => 'ย้ายบท', + 'chapters_move_named' => 'ย้ายบท :chapterName', + 'chapters_copy' => 'คัดลอกบท', + 'chapters_copy_success' => 'คัดลอกบทสำเร็จแล้ว', + 'chapters_permissions' => 'สิทธิ์บท', + 'chapters_empty' => 'ยังไม่มีหน้าในบทนี้', + 'chapters_permissions_active' => 'สิทธิ์บทเปิดใช้งานอยู่', + 'chapters_permissions_success' => 'อัปเดตสิทธิ์บทแล้ว', + 'chapters_search_this' => 'ค้นหาในบทนี้', + 'chapter_sort_book' => 'จัดเรียงหนังสือ', + + // Pages + 'page' => 'หน้า', + 'pages' => 'หน้า', + 'x_pages' => ':count หน้า', + 'pages_popular' => 'หน้ายอดนิยม', + 'pages_new' => 'หน้าใหม่', + 'pages_attachments' => 'ไฟล์แนบ', + 'pages_navigation' => 'การนำทางหน้า', + 'pages_delete' => 'ลบหน้า', + 'pages_delete_named' => 'ลบหน้า :pageName', + 'pages_delete_draft_named' => 'ลบร่างหน้า :pageName', + 'pages_delete_draft' => 'ลบร่างหน้า', + 'pages_delete_success' => 'ลบหน้าแล้ว', + 'pages_delete_draft_success' => 'ลบร่างหน้าแล้ว', + 'pages_delete_warning_template' => 'หน้านี้ถูกใช้งานเป็นแม่แบบหน้าเริ่มต้นของหนังสือหรือบท หลังจากลบหน้านี้ หนังสือหรือบทดังกล่าวจะไม่มีแม่แบบหน้าเริ่มต้น', + 'pages_delete_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการลบหน้านี้?', + 'pages_delete_draft_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการลบร่างหน้านี้?', + 'pages_editing_named' => 'กำลังแก้ไขหน้า :pageName', + 'pages_edit_draft_options' => 'ตัวเลือกร่าง', + 'pages_edit_save_draft' => 'บันทึกร่าง', + 'pages_edit_draft' => 'แก้ไขร่างหน้า', + 'pages_editing_draft' => 'กำลังแก้ไขร่าง', + 'pages_editing_page' => 'กำลังแก้ไขหน้า', + 'pages_edit_draft_save_at' => 'บันทึกร่างเมื่อ ', + 'pages_edit_delete_draft' => 'ลบร่าง', + 'pages_edit_delete_draft_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการลบร่างหน้านี้? การเปลี่ยนแปลงทั้งหมดตั้งแต่บันทึกครั้งล่าสุดจะสูญหาย และตัวแก้ไขจะโหลดเนื้อหาที่บันทึกล่าสุด', + 'pages_edit_discard_draft' => 'ยกเลิกร่าง', + 'pages_edit_switch_to_markdown' => 'เปลี่ยนไปใช้ตัวแก้ไข Markdown', + 'pages_edit_switch_to_markdown_clean' => '(เนื้อหาสะอาด)', + 'pages_edit_switch_to_markdown_stable' => '(เนื้อหาเสถียร)', + 'pages_edit_switch_to_wysiwyg' => 'เปลี่ยนไปใช้ตัวแก้ไข WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg' => 'เปลี่ยนไปใช้ WYSIWYG ใหม่', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(กำลังทดสอบ Beta)', + 'pages_edit_set_changelog' => 'ตั้งค่า Changelog', + 'pages_edit_enter_changelog_desc' => 'กรอกคำอธิบายสั้นๆ เกี่ยวกับการเปลี่ยนแปลงที่ทำ', + 'pages_edit_enter_changelog' => 'กรอก Changelog', + 'pages_editor_switch_title' => 'เปลี่ยนตัวแก้ไข', + 'pages_editor_switch_are_you_sure' => 'คุณแน่ใจหรือไม่ว่าต้องการเปลี่ยนตัวแก้ไขสำหรับหน้านี้?', + 'pages_editor_switch_consider_following' => 'โปรดพิจารณาสิ่งต่อไปนี้เมื่อเปลี่ยนตัวแก้ไข:', + 'pages_editor_switch_consideration_a' => 'เมื่อบันทึกแล้ว ตัวแก้ไขใหม่จะถูกใช้โดยผู้แก้ไขในอนาคต รวมถึงผู้ที่อาจไม่สามารถเปลี่ยนประเภทตัวแก้ไขได้เอง', + 'pages_editor_switch_consideration_b' => 'อาจทำให้รายละเอียดและไวยากรณ์บางอย่างสูญหายในบางสถานการณ์', + 'pages_editor_switch_consideration_c' => 'การเปลี่ยนแปลงแท็กหรือ changelog ที่ทำตั้งแต่บันทึกครั้งล่าสุดจะไม่ถูกบันทึกหลังจากเปลี่ยนนี้', + 'pages_save' => 'บันทึกหน้า', + 'pages_title' => 'ชื่อหน้า', + 'pages_name' => 'ชื่อหน้า', + 'pages_md_editor' => 'ตัวแก้ไข', + 'pages_md_preview' => 'ดูตัวอย่าง', + 'pages_md_insert_image' => 'แทรกรูปภาพ', + 'pages_md_insert_link' => 'แทรกลิงก์รายการ', + 'pages_md_insert_drawing' => 'แทรกภาพวาด', + 'pages_md_show_preview' => 'แสดงตัวอย่าง', + 'pages_md_sync_scroll' => 'ซิงค์การเลื่อนหน้าตัวอย่าง', + 'pages_md_plain_editor' => 'ตัวแก้ไขข้อความธรรมดา', + 'pages_drawing_unsaved' => 'พบภาพวาดที่ยังไม่บันทึก', + 'pages_drawing_unsaved_confirm' => 'พบข้อมูลภาพวาดที่ยังไม่บันทึกจากการบันทึกที่ล้มเหลวก่อนหน้า ต้องการกู้คืนและแก้ไขภาพวาดที่ยังไม่บันทึกนี้ต่อหรือไม่?', + 'pages_not_in_chapter' => 'หน้านี้ไม่ได้อยู่ในบท', + 'pages_move' => 'ย้ายหน้า', + 'pages_copy' => 'คัดลอกหน้า', + 'pages_copy_desination' => 'ปลายทางการคัดลอก', + 'pages_copy_success' => 'คัดลอกหน้าสำเร็จแล้ว', + 'pages_permissions' => 'สิทธิ์หน้า', + 'pages_permissions_success' => 'อัปเดตสิทธิ์หน้าแล้ว', + 'pages_revision' => 'การแก้ไข', + 'pages_revisions' => 'การแก้ไขหน้า', + 'pages_revisions_desc' => 'แสดงรายการการแก้ไขทั้งหมดของหน้านี้ คุณสามารถดูย้อนหลัง เปรียบเทียบ และกู้คืนเวอร์ชันเก่าได้หากมีสิทธิ์ ประวัติทั้งหมดอาจไม่แสดงครบเนื่องจากการตั้งค่าระบบอาจลบการแก้ไขเก่าโดยอัตโนมัติ', + 'pages_revisions_named' => 'การแก้ไขหน้าสำหรับ :pageName', + 'pages_revision_named' => 'การแก้ไขหน้าสำหรับ :pageName', + 'pages_revision_restored_from' => 'กู้คืนจาก #:id; :summary', + 'pages_revisions_created_by' => 'สร้างโดย', + 'pages_revisions_date' => 'วันที่แก้ไข', + 'pages_revisions_number' => '#', + 'pages_revisions_sort_number' => 'หมายเลขการแก้ไข', + 'pages_revisions_numbered' => 'การแก้ไข #:id', + 'pages_revisions_numbered_changes' => 'การเปลี่ยนแปลงในการแก้ไข #:id', + 'pages_revisions_editor' => 'ประเภทตัวแก้ไข', + 'pages_revisions_changelog' => 'Changelog', + 'pages_revisions_changes' => 'การเปลี่ยนแปลง', + 'pages_revisions_current' => 'เวอร์ชันปัจจุบัน', + 'pages_revisions_preview' => 'ดูตัวอย่าง', + 'pages_revisions_restore' => 'กู้คืน', + 'pages_revisions_none' => 'หน้านี้ยังไม่มีการแก้ไข', + 'pages_copy_link' => 'คัดลอกลิงก์', + 'pages_edit_content_link' => 'ข้ามไปยังส่วนในตัวแก้ไข', + 'pages_pointer_enter_mode' => 'เข้าสู่โหมดเลือกส่วน', + 'pages_pointer_label' => 'ตัวเลือกส่วนหน้า', + 'pages_pointer_permalink' => 'ลิงก์ถาวรส่วนหน้า', + 'pages_pointer_include_tag' => 'แท็กรวมส่วนหน้า', + 'pages_pointer_toggle_link' => 'โหมดลิงก์ถาวร กดเพื่อแสดงแท็กรวม', + 'pages_pointer_toggle_include' => 'โหมดแท็กรวม กดเพื่อแสดงลิงก์ถาวร', + 'pages_permissions_active' => 'สิทธิ์หน้าเปิดใช้งานอยู่', + 'pages_initial_revision' => 'เผยแพร่ครั้งแรก', + 'pages_references_update_revision' => 'ระบบอัปเดตลิงก์ภายในอัตโนมัติ', + 'pages_initial_name' => 'หน้าใหม่', + 'pages_editing_draft_notification' => 'คุณกำลังแก้ไขร่างที่บันทึกล่าสุดเมื่อ :timeDiff', + 'pages_draft_edited_notification' => 'หน้านี้ได้รับการอัปเดตตั้งแต่นั้นมา แนะนำให้ยกเลิกร่างนี้', + 'pages_draft_page_changed_since_creation' => 'หน้านี้ได้รับการอัปเดตตั้งแต่สร้างร่างนี้ แนะนำให้ยกเลิกร่างหรือระมัดระวังไม่ให้เขียนทับการเปลี่ยนแปลงของหน้า', + 'pages_draft_edit_active' => [ + 'start_a' => 'มีผู้ใช้ :count คนกำลังแก้ไขหน้านี้', + 'start_b' => ':userName กำลังแก้ไขหน้านี้', + 'time_a' => 'ตั้งแต่อัปเดตหน้าล่าสุด', + 'time_b' => 'ใน :minCount นาทีที่ผ่านมา', + 'message' => ':start :time โปรดระวังอย่าเขียนทับการแก้ไขของกันและกัน!', + ], + 'pages_draft_discarded' => 'ยกเลิกร่างแล้ว! ตัวแก้ไขได้รับการอัปเดตด้วยเนื้อหาปัจจุบันของหน้า', + 'pages_draft_deleted' => 'ลบร่างแล้ว! ตัวแก้ไขได้รับการอัปเดตด้วยเนื้อหาปัจจุบันของหน้า', + 'pages_specific' => 'หน้าเฉพาะ', + 'pages_is_template' => 'แม่แบบหน้า', + + // Editor Sidebar + 'toggle_sidebar' => 'แสดง/ซ่อนแถบด้านข้าง', + 'page_tags' => 'แท็กหน้า', + 'chapter_tags' => 'แท็กบท', + 'book_tags' => 'แท็กหนังสือ', + 'shelf_tags' => 'แท็กชั้นวาง', + 'tag' => 'แท็ก', + 'tags' => 'แท็ก', + 'tags_index_desc' => 'แท็กสามารถนำไปใช้กับเนื้อหาในระบบเพื่อจัดหมวดหมู่แบบยืดหยุ่น แท็กสามารถมีทั้งคีย์และค่า โดยค่าไม่บังคับ เมื่อกำหนดแล้ว สามารถค้นหาเนื้อหาด้วยชื่อและค่าของแท็กได้', + 'tag_name' => 'ชื่อแท็ก', + 'tag_value' => 'ค่าแท็ก (ไม่บังคับ)', + 'tags_explain' => "เพิ่มแท็กเพื่อจัดหมวดหมู่เนื้อหาให้ดีขึ้น \n คุณสามารถกำหนดค่าให้แท็กเพื่อการจัดระเบียบที่ละเอียดขึ้น", + 'tags_add' => 'เพิ่มแท็ก', + 'tags_remove' => 'ลบแท็กนี้', + 'tags_usages' => 'การใช้งานแท็กทั้งหมด', + 'tags_assigned_pages' => 'กำหนดให้หน้า', + 'tags_assigned_chapters' => 'กำหนดให้บท', + 'tags_assigned_books' => 'กำหนดให้หนังสือ', + 'tags_assigned_shelves' => 'กำหนดให้ชั้นวาง', + 'tags_x_unique_values' => ':count ค่าที่ไม่ซ้ำ', + 'tags_all_values' => 'ค่าทั้งหมด', + 'tags_view_tags' => 'ดูแท็ก', + 'tags_view_existing_tags' => 'ดูแท็กที่มีอยู่', + 'tags_list_empty_hint' => 'แท็กสามารถกำหนดได้ผ่านแถบด้านข้างของตัวแก้ไขหน้า หรือขณะแก้ไขรายละเอียดของหนังสือ บท หรือชั้นวาง', + 'attachments' => 'ไฟล์แนบ', + 'attachments_explain' => 'อัปโหลดไฟล์หรือแนบลิงก์เพื่อแสดงในหน้านี้ จะมองเห็นได้ในแถบด้านข้างของหน้า', + 'attachments_explain_instant_save' => 'การเปลี่ยนแปลงที่นี่จะบันทึกทันที', + 'attachments_upload' => 'อัปโหลดไฟล์', + 'attachments_link' => 'แนบลิงก์', + 'attachments_upload_drop' => 'หรือคุณสามารถลากและวางไฟล์ที่นี่เพื่ออัปโหลดเป็นไฟล์แนบ', + 'attachments_set_link' => 'ตั้งค่าลิงก์', + 'attachments_delete' => 'คุณแน่ใจหรือไม่ว่าต้องการลบไฟล์แนบนี้?', + 'attachments_dropzone' => 'วางไฟล์ที่นี่เพื่ออัปโหลด', + 'attachments_no_files' => 'ยังไม่มีไฟล์ที่อัปโหลด', + 'attachments_explain_link' => 'คุณสามารถแนบลิงก์แทนการอัปโหลดไฟล์ได้ ลิงก์อาจเป็นลิงก์ไปยังหน้าอื่นหรือไฟล์ในคลาวด์', + 'attachments_link_name' => 'ชื่อลิงก์', + 'attachment_link' => 'ลิงก์ไฟล์แนบ', + 'attachments_link_url' => 'ลิงก์ไปยังไฟล์', + 'attachments_link_url_hint' => 'URL ของเว็บไซต์หรือไฟล์', + 'attach' => 'แนบ', + 'attachments_insert_link' => 'เพิ่มลิงก์ไฟล์แนบในหน้า', + 'attachments_edit_file' => 'แก้ไขไฟล์', + 'attachments_edit_file_name' => 'ชื่อไฟล์', + 'attachments_edit_drop_upload' => 'วางไฟล์หรือคลิกที่นี่เพื่ออัปโหลดและแทนที่', + 'attachments_order_updated' => 'อัปเดตลำดับไฟล์แนบแล้ว', + 'attachments_updated_success' => 'อัปเดตรายละเอียดไฟล์แนบแล้ว', + 'attachments_deleted' => 'ลบไฟล์แนบแล้ว', + 'attachments_file_uploaded' => 'อัปโหลดไฟล์สำเร็จแล้ว', + 'attachments_file_updated' => 'อัปเดตไฟล์สำเร็จแล้ว', + 'attachments_link_attached' => 'แนบลิงก์ไปยังหน้าสำเร็จแล้ว', + 'templates' => 'แม่แบบ', + 'templates_set_as_template' => 'หน้านี้เป็นแม่แบบ', + 'templates_explain_set_as_template' => 'คุณสามารถตั้งหน้านี้เป็นแม่แบบเพื่อให้นำเนื้อหาไปใช้เมื่อสร้างหน้าอื่น ผู้ใช้อื่นจะสามารถใช้แม่แบบนี้ได้หากมีสิทธิ์ดูหน้านี้', + 'templates_replace_content' => 'แทนที่เนื้อหาหน้า', + 'templates_append_content' => 'ต่อท้ายเนื้อหาหน้า', + 'templates_prepend_content' => 'เพิ่มก่อนเนื้อหาหน้า', + + // Profile View + 'profile_user_for_x' => 'ผู้ใช้มาแล้ว :time', + 'profile_created_content' => 'เนื้อหาที่สร้าง', + 'profile_not_created_pages' => ':userName ยังไม่ได้สร้างหน้าใด', + 'profile_not_created_chapters' => ':userName ยังไม่ได้สร้างบทใด', + 'profile_not_created_books' => ':userName ยังไม่ได้สร้างหนังสือใด', + 'profile_not_created_shelves' => ':userName ยังไม่ได้สร้างชั้นวางใด', + + // Comments + 'comment' => 'ความคิดเห็น', + 'comments' => 'ความคิดเห็น', + 'comment_add' => 'เพิ่มความคิดเห็น', + 'comment_none' => 'ไม่มีความคิดเห็น', + 'comment_placeholder' => 'เขียนความคิดเห็นที่นี่', + 'comment_thread_count' => ':count เธรดความคิดเห็น', + 'comment_archived_count' => ':count ที่เก็บถาวร', + 'comment_archived_threads' => 'เธรดที่เก็บถาวร', + 'comment_save' => 'บันทึกความคิดเห็น', + 'comment_new' => 'ความคิดเห็นใหม่', + 'comment_created' => 'แสดงความคิดเห็นเมื่อ :createDiff', + 'comment_updated' => 'แก้ไขเมื่อ :updateDiff โดย :username', + 'comment_updated_indicator' => 'แก้ไขแล้ว', + 'comment_deleted_success' => 'ลบความคิดเห็นแล้ว', + 'comment_created_success' => 'เพิ่มความคิดเห็นแล้ว', + 'comment_updated_success' => 'แก้ไขความคิดเห็นแล้ว', + 'comment_archive_success' => 'เก็บถาวรความคิดเห็นแล้ว', + 'comment_unarchive_success' => 'ยกเลิกการเก็บถาวรความคิดเห็นแล้ว', + 'comment_view' => 'ดูความคิดเห็น', + 'comment_jump_to_thread' => 'ไปยังเธรด', + 'comment_delete_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการลบความคิดเห็นนี้?', + 'comment_in_reply_to' => 'ตอบกลับ :commentId', + 'comment_reference' => 'อ้างอิง', + 'comment_reference_outdated' => '(ล้าสมัย)', + 'comment_editor_explain' => 'แสดงความคิดเห็นที่มีในหน้านี้ สามารถเพิ่มและจัดการความคิดเห็นได้เมื่อดูหน้าที่บันทึกแล้ว', + + // Revision + 'revision_delete_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการลบการแก้ไขนี้?', + 'revision_restore_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการกู้คืนการแก้ไขนี้? เนื้อหาปัจจุบันของหน้าจะถูกแทนที่', + 'revision_cannot_delete_latest' => 'ไม่สามารถลบการแก้ไขล่าสุดได้', + + // Copy view + 'copy_consider' => 'โปรดพิจารณาสิ่งต่อไปนี้เมื่อคัดลอกเนื้อหา', + 'copy_consider_permissions' => 'การตั้งค่าสิทธิ์แบบกำหนดเองจะไม่ถูกคัดลอก', + 'copy_consider_owner' => 'คุณจะเป็นเจ้าของเนื้อหาที่คัดลอกทั้งหมด', + 'copy_consider_images' => 'ไฟล์รูปภาพในหน้าจะไม่ถูกทำสำเนา และรูปภาพต้นฉบับจะยังคงเชื่อมกับหน้าที่อัปโหลดไว้เดิม', + 'copy_consider_attachments' => 'ไฟล์แนบในหน้าจะไม่ถูกคัดลอก', + 'copy_consider_access' => 'การเปลี่ยนตำแหน่ง เจ้าของ หรือสิทธิ์อาจทำให้เนื้อหานี้เข้าถึงได้โดยผู้ที่ไม่เคยมีสิทธิ์มาก่อน', + + // Conversions + 'convert_to_shelf' => 'แปลงเป็นชั้นวาง', + 'convert_to_shelf_contents_desc' => 'คุณสามารถแปลงหนังสือนี้เป็นชั้นวางใหม่ที่มีเนื้อหาเดิม บทในหนังสือนี้จะถูกแปลงเป็นหนังสือใหม่ หากหนังสือนี้มีหน้าที่ไม่อยู่ในบท หนังสือนี้จะถูกเปลี่ยนชื่อและเก็บหน้าเหล่านั้น และจะกลายเป็นส่วนหนึ่งของชั้นวางใหม่', + 'convert_to_shelf_permissions_desc' => 'สิทธิ์ที่กำหนดบนหนังสือนี้จะถูกคัดลอกไปยังชั้นวางใหม่และหนังสือลูกทั้งหมดที่ไม่มีสิทธิ์ของตัวเอง โปรดทราบว่าสิทธิ์บนชั้นวางไม่ส่งต่อไปยังเนื้อหาภายในโดยอัตโนมัติเหมือนกับหนังสือ', + 'convert_book' => 'แปลงหนังสือ', + 'convert_book_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการแปลงหนังสือนี้?', + 'convert_undo_warning' => 'การดำเนินการนี้ยากที่จะยกเลิก', + 'convert_to_book' => 'แปลงเป็นหนังสือ', + 'convert_to_book_desc' => 'คุณสามารถแปลงบทนี้เป็นหนังสือใหม่ที่มีเนื้อหาเดิม สิทธิ์ที่กำหนดบนบทนี้จะถูกคัดลอกไปยังหนังสือใหม่ แต่สิทธิ์ที่รับมาจากหนังสือแม่จะไม่ถูกคัดลอก ซึ่งอาจทำให้การควบคุมการเข้าถึงเปลี่ยนแปลงได้', + 'convert_chapter' => 'แปลงบท', + 'convert_chapter_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการแปลงบทนี้?', + + // References + 'references' => 'การอ้างอิง', + 'references_none' => 'ยังไม่มีการอ้างอิงที่ติดตามไปยังรายการนี้', + 'references_to_desc' => 'แสดงรายการเนื้อหาทั้งหมดในระบบที่ลิงก์ไปยังรายการนี้', + + // Watch Options + 'watch' => 'ติดตาม', + 'watch_title_default' => 'การตั้งค่าเริ่มต้น', + 'watch_desc_default' => 'กลับไปใช้การตั้งค่าการแจ้งเตือนเริ่มต้นของคุณ', + 'watch_title_ignore' => 'ไม่สนใจ', + 'watch_desc_ignore' => 'ไม่รับการแจ้งเตือนใดๆ รวมถึงจากการตั้งค่าระดับผู้ใช้', + 'watch_title_new' => 'หน้าใหม่', + 'watch_desc_new' => 'แจ้งเตือนเมื่อมีการสร้างหน้าใหม่ในรายการนี้', + 'watch_title_updates' => 'การอัปเดตหน้าทั้งหมด', + 'watch_desc_updates' => 'แจ้งเตือนเมื่อมีหน้าใหม่และการแก้ไขหน้า', + 'watch_desc_updates_page' => 'แจ้งเตือนเมื่อมีการแก้ไขหน้า', + 'watch_title_comments' => 'การอัปเดตและความคิดเห็นทั้งหมด', + 'watch_desc_comments' => 'แจ้งเตือนเมื่อมีหน้าใหม่ การแก้ไขหน้า และความคิดเห็นใหม่', + 'watch_desc_comments_page' => 'แจ้งเตือนเมื่อมีการแก้ไขหน้าและความคิดเห็นใหม่', + 'watch_change_default' => 'เปลี่ยนการตั้งค่าการแจ้งเตือนเริ่มต้น', + 'watch_detail_ignore' => 'ไม่รับการแจ้งเตือน', + 'watch_detail_new' => 'ติดตามหน้าใหม่', + 'watch_detail_updates' => 'ติดตามหน้าใหม่และการอัปเดต', + 'watch_detail_comments' => 'ติดตามหน้าใหม่ การอัปเดต และความคิดเห็น', + 'watch_detail_parent_book' => 'ติดตามผ่านหนังสือแม่', + 'watch_detail_parent_book_ignore' => 'ไม่รับการแจ้งเตือนผ่านหนังสือแม่', + 'watch_detail_parent_chapter' => 'ติดตามผ่านบทแม่', + 'watch_detail_parent_chapter_ignore' => 'ไม่รับการแจ้งเตือนผ่านบทแม่', +]; diff --git a/lang/th/errors.php b/lang/th/errors.php new file mode 100644 index 000000000..61f1ed82c --- /dev/null +++ b/lang/th/errors.php @@ -0,0 +1,135 @@ + 'คุณไม่มีสิทธิ์เข้าถึงหน้าที่ร้องขอ', + 'permissionJson' => 'คุณไม่มีสิทธิ์ดำเนินการที่ร้องขอ', + + // Auth + 'error_user_exists_different_creds' => 'มีผู้ใช้ที่ใช้อีเมล :email อยู่แล้วแต่ใช้ข้อมูลประจำตัวต่างกัน', + 'auth_pre_register_theme_prevention' => 'ไม่สามารถลงทะเบียนบัญชีผู้ใช้สำหรับข้อมูลที่ให้มาได้', + 'email_already_confirmed' => 'ยืนยันอีเมลแล้ว กรุณาลองเข้าสู่ระบบ', + 'email_confirmation_invalid' => 'โทเค็นยืนยันนี้ไม่ถูกต้องหรือถูกใช้ไปแล้ว กรุณาลองลงทะเบียนใหม่', + 'email_confirmation_expired' => 'โทเค็นยืนยันหมดอายุแล้ว ส่งอีเมลยืนยันใหม่ให้แล้ว', + 'email_confirmation_awaiting' => 'ที่อยู่อีเมลของบัญชีที่ใช้งานอยู่ต้องได้รับการยืนยัน', + 'ldap_fail_anonymous' => 'การเข้าถึง LDAP ล้มเหลวโดยใช้การเชื่อมต่อแบบไม่ระบุตัวตน', + 'ldap_fail_authed' => 'การเข้าถึง LDAP ล้มเหลวโดยใช้ข้อมูล dn และรหัสผ่านที่กำหนด', + 'ldap_extension_not_installed' => 'ไม่ได้ติดตั้ง PHP extension สำหรับ LDAP', + 'ldap_cannot_connect' => 'ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ LDAP ได้ การเชื่อมต่อเริ่มต้นล้มเหลว', + 'saml_already_logged_in' => 'เข้าสู่ระบบแล้ว', + 'saml_no_email_address' => 'ไม่พบที่อยู่อีเมลสำหรับผู้ใช้นี้ในข้อมูลที่ระบบยืนยันตัวตนภายนอกส่งมา', + 'saml_invalid_response_id' => 'คำขอจากระบบยืนยันตัวตนภายนอกไม่ได้รับการยอมรับจากกระบวนการที่เริ่มต้นโดยแอปพลิเคชันนี้ การนำทางย้อนกลับหลังเข้าสู่ระบบอาจทำให้เกิดปัญหานี้', + 'saml_fail_authed' => 'การเข้าสู่ระบบด้วย :system ล้มเหลว ระบบไม่ได้ให้การอนุญาตที่สำเร็จ', + 'oidc_already_logged_in' => 'เข้าสู่ระบบแล้ว', + 'oidc_no_email_address' => 'ไม่พบที่อยู่อีเมลสำหรับผู้ใช้นี้ในข้อมูลที่ระบบยืนยันตัวตนภายนอกส่งมา', + 'oidc_fail_authed' => 'การเข้าสู่ระบบด้วย :system ล้มเหลว ระบบไม่ได้ให้การอนุญาตที่สำเร็จ', + 'social_no_action_defined' => 'ไม่ได้กำหนดการดำเนินการ', + 'social_login_bad_response' => "เกิดข้อผิดพลาดระหว่างเข้าสู่ระบบด้วย :socialAccount: \n:error", + 'social_account_in_use' => 'บัญชี :socialAccount นี้ถูกใช้งานแล้ว ลองเข้าสู่ระบบผ่านตัวเลือก :socialAccount', + 'social_account_email_in_use' => 'อีเมล :email ถูกใช้งานแล้ว หากมีบัญชีอยู่แล้ว คุณสามารถเชื่อมต่อบัญชี :socialAccount จากการตั้งค่าโปรไฟล์ได้', + 'social_account_existing' => ':socialAccount นี้เชื่อมต่อกับโปรไฟล์ของคุณแล้ว', + 'social_account_already_used_existing' => 'บัญชี :socialAccount นี้ถูกใช้งานโดยผู้ใช้อื่นแล้ว', + 'social_account_not_used' => 'บัญชี :socialAccount นี้ไม่ได้เชื่อมต่อกับผู้ใช้ใด กรุณาแนบในการตั้งค่าโปรไฟล์', + 'social_account_register_instructions' => 'หากยังไม่มีบัญชี คุณสามารถลงทะเบียนโดยใช้ตัวเลือก :socialAccount', + 'social_driver_not_found' => 'ไม่พบ Social driver', + 'social_driver_not_configured' => 'การตั้งค่า Social ของ :socialAccount ไม่ถูกต้อง', + 'invite_token_expired' => 'ลิงก์เชิญนี้หมดอายุแล้ว คุณสามารถลองรีเซ็ตรหัสผ่านบัญชีแทนได้', + 'login_user_not_found' => 'ไม่พบผู้ใช้สำหรับการดำเนินการนี้', + + // System + 'path_not_writable' => 'ไม่สามารถอัปโหลดไปยังพาธ :filePath ได้ โปรดตรวจสอบว่าเซิร์ฟเวอร์มีสิทธิ์เขียนได้', + 'cannot_get_image_from_url' => 'ไม่สามารถดึงรูปภาพจาก :url ได้', + 'cannot_create_thumbs' => 'เซิร์ฟเวอร์ไม่สามารถสร้างภาพย่อได้ โปรดตรวจสอบว่าติดตั้ง PHP extension GD แล้ว', + 'server_upload_limit' => 'เซิร์ฟเวอร์ไม่อนุญาตให้อัปโหลดไฟล์ขนาดนี้ กรุณาลองใช้ไฟล์ขนาดเล็กกว่า', + 'server_post_limit' => 'เซิร์ฟเวอร์ไม่สามารถรับข้อมูลในปริมาณที่กำหนดได้ ลองใหม่ด้วยข้อมูลน้อยลงหรือไฟล์ขนาดเล็กกว่า', + 'uploaded' => 'เซิร์ฟเวอร์ไม่อนุญาตให้อัปโหลดไฟล์ขนาดนี้ กรุณาลองใช้ไฟล์ขนาดเล็กกว่า', + + // Drawing & Images + 'image_upload_error' => 'เกิดข้อผิดพลาดขณะอัปโหลดรูปภาพ', + 'image_upload_type_error' => 'ประเภทรูปภาพที่อัปโหลดไม่ถูกต้อง', + 'image_upload_replace_type' => 'การแทนที่ไฟล์รูปภาพต้องใช้ประเภทเดียวกัน', + 'image_upload_memory_limit' => 'ไม่สามารถจัดการการอัปโหลดรูปภาพและ/หรือสร้างภาพย่อได้เนื่องจากทรัพยากรระบบไม่เพียงพอ', + 'image_thumbnail_memory_limit' => 'ไม่สามารถสร้างขนาดรูปภาพต่างๆ ได้เนื่องจากทรัพยากรระบบไม่เพียงพอ', + 'image_gallery_thumbnail_memory_limit' => 'ไม่สามารถสร้างภาพย่อแกลเลอรีได้เนื่องจากทรัพยากรระบบไม่เพียงพอ', + 'drawing_data_not_found' => 'ไม่สามารถโหลดข้อมูลภาพวาดได้ ไฟล์ภาพวาดอาจไม่มีอยู่แล้วหรือคุณไม่มีสิทธิ์เข้าถึง', + + // Attachments + 'attachment_not_found' => 'ไม่พบไฟล์แนบ', + 'attachment_upload_error' => 'เกิดข้อผิดพลาดขณะอัปโหลดไฟล์แนบ', + + // Pages + 'page_draft_autosave_fail' => 'บันทึกร่างล้มเหลว โปรดตรวจสอบการเชื่อมต่ออินเทอร์เน็ตก่อนบันทึกหน้านี้', + 'page_draft_delete_fail' => 'ลบร่างหน้าและดึงเนื้อหาที่บันทึกปัจจุบันล้มเหลว', + 'page_custom_home_deletion' => 'ไม่สามารถลบหน้าได้ในขณะที่ตั้งเป็นหน้าแรก', + + // Entities + 'entity_not_found' => 'ไม่พบรายการ', + 'bookshelf_not_found' => 'ไม่พบชั้นวาง', + 'book_not_found' => 'ไม่พบหนังสือ', + 'page_not_found' => 'ไม่พบหน้า', + 'chapter_not_found' => 'ไม่พบบท', + 'selected_book_not_found' => 'ไม่พบหนังสือที่เลือก', + 'selected_book_chapter_not_found' => 'ไม่พบหนังสือหรือบทที่เลือก', + 'guests_cannot_save_drafts' => 'ผู้เยี่ยมชมไม่สามารถบันทึกร่างได้', + + // Users + 'users_cannot_delete_only_admin' => 'ไม่สามารถลบผู้ดูแลระบบคนเดียวได้', + 'users_cannot_delete_guest' => 'ไม่สามารถลบผู้ใช้แบบผู้เยี่ยมชมได้', + 'users_could_not_send_invite' => 'ไม่สามารถสร้างผู้ใช้ได้เนื่องจากส่งอีเมลเชิญล้มเหลว', + + // Roles + 'role_cannot_be_edited' => 'บทบาทนี้ไม่สามารถแก้ไขได้', + 'role_system_cannot_be_deleted' => 'บทบาทนี้เป็นบทบาทระบบและไม่สามารถลบได้', + 'role_registration_default_cannot_delete' => 'บทบาทนี้ไม่สามารถลบได้ในขณะที่ตั้งเป็นบทบาทลงทะเบียนเริ่มต้น', + 'role_cannot_remove_only_admin' => 'ผู้ใช้นี้เป็นผู้ใช้คนเดียวที่ได้รับบทบาทผู้ดูแลระบบ กรุณากำหนดบทบาทผู้ดูแลระบบให้ผู้ใช้อื่นก่อนที่จะลบออกที่นี่', + + // Comments + 'comment_list' => 'เกิดข้อผิดพลาดขณะดึงความคิดเห็น', + 'cannot_add_comment_to_draft' => 'ไม่สามารถเพิ่มความคิดเห็นในร่างได้', + 'comment_add' => 'เกิดข้อผิดพลาดขณะเพิ่ม/อัปเดตความคิดเห็น', + 'comment_delete' => 'เกิดข้อผิดพลาดขณะลบความคิดเห็น', + 'empty_comment' => 'ไม่สามารถเพิ่มความคิดเห็นที่ว่างเปล่าได้', + + // Error pages + '404_page_not_found' => 'ไม่พบหน้า', + 'sorry_page_not_found' => 'ขออภัย ไม่พบหน้าที่คุณกำลังมองหา', + 'sorry_page_not_found_permission_warning' => 'หากคุณคาดว่าหน้านี้มีอยู่ คุณอาจไม่มีสิทธิ์ดูหน้านี้', + 'image_not_found' => 'ไม่พบรูปภาพ', + 'image_not_found_subtitle' => 'ขออภัย ไม่พบไฟล์รูปภาพที่คุณกำลังมองหา', + 'image_not_found_details' => 'หากคุณคาดว่ารูปภาพนี้มีอยู่ อาจถูกลบไปแล้ว', + 'return_home' => 'กลับไปหน้าแรก', + 'error_occurred' => 'เกิดข้อผิดพลาด', + 'app_down' => ':appName ไม่พร้อมใช้งานในขณะนี้', + 'back_soon' => 'จะกลับมาให้บริการเร็วๆ นี้', + + // Import + 'import_zip_cant_read' => 'ไม่สามารถอ่านไฟล์ ZIP ได้', + 'import_zip_cant_decode_data' => 'ไม่สามารถค้นหาและถอดรหัสเนื้อหา data.json ใน ZIP ได้', + 'import_zip_no_data' => 'ข้อมูลในไฟล์ ZIP ไม่มีเนื้อหาหนังสือ บท หรือหน้าที่คาดไว้', + 'import_zip_data_too_large' => 'เนื้อหา data.json ใน ZIP เกินขนาดอัปโหลดสูงสุดที่กำหนดในแอปพลิเคชัน', + 'import_validation_failed' => 'ตรวจสอบ ZIP นำเข้าล้มเหลวพร้อมข้อผิดพลาด:', + 'import_zip_failed_notification' => 'นำเข้าไฟล์ ZIP ล้มเหลว', + 'import_perms_books' => 'คุณขาดสิทธิ์ที่จำเป็นในการสร้างหนังสือ', + 'import_perms_chapters' => 'คุณขาดสิทธิ์ที่จำเป็นในการสร้างบท', + 'import_perms_pages' => 'คุณขาดสิทธิ์ที่จำเป็นในการสร้างหน้า', + 'import_perms_images' => 'คุณขาดสิทธิ์ที่จำเป็นในการสร้างรูปภาพ', + 'import_perms_attachments' => 'คุณขาดสิทธิ์ที่จำเป็นในการสร้างไฟล์แนบ', + + // API errors + 'api_no_authorization_found' => 'ไม่พบโทเค็นการอนุญาตในคำขอ', + 'api_bad_authorization_format' => 'พบโทเค็นการอนุญาตในคำขอแต่รูปแบบดูเหมือนไม่ถูกต้อง', + 'api_user_token_not_found' => 'ไม่พบ API token ที่ตรงกับโทเค็นการอนุญาตที่ให้มา', + 'api_incorrect_token_secret' => 'รหัสลับที่ให้มาสำหรับ API token ที่ใช้ไม่ถูกต้อง', + 'api_user_no_api_permission' => 'เจ้าของ API token ที่ใช้ไม่มีสิทธิ์เรียกใช้ API', + 'api_user_token_expired' => 'โทเค็นการอนุญาตที่ใช้หมดอายุแล้ว', + 'api_cookie_auth_only_get' => 'อนุญาตเฉพาะคำขอ GET เมื่อใช้ API ด้วยการยืนยันตัวตนแบบ cookie', + + // Settings & Maintenance + 'maintenance_test_email_failure' => 'เกิดข้อผิดพลาดขณะส่งอีเมลทดสอบ:', + + // HTTP errors + 'http_ssr_url_no_match' => 'URL ไม่ตรงกับโฮสต์ SSR ที่อนุญาตที่กำหนดค่าไว้', +]; diff --git a/lang/th/notifications.php b/lang/th/notifications.php new file mode 100644 index 000000000..661c83fdb --- /dev/null +++ b/lang/th/notifications.php @@ -0,0 +1,29 @@ + 'ความคิดเห็นใหม่ในหน้า: :pageName', + 'new_comment_intro' => 'มีผู้ใช้แสดงความคิดเห็นในหน้าใน :appName:', + 'new_page_subject' => 'หน้าใหม่: :pageName', + 'new_page_intro' => 'มีการสร้างหน้าใหม่ใน :appName:', + 'updated_page_subject' => 'แก้ไขหน้า: :pageName', + 'updated_page_intro' => 'มีการแก้ไขหน้าใน :appName:', + 'updated_page_debounce' => 'เพื่อป้องกันการแจ้งเตือนจำนวนมาก คุณจะไม่ได้รับการแจ้งเตือนสำหรับการแก้ไขเพิ่มเติมในหน้านี้โดยผู้แก้ไขคนเดิมในช่วงเวลาหนึ่ง', + 'comment_mention_subject' => 'คุณถูกกล่าวถึงในความคิดเห็นในหน้า: :pageName', + 'comment_mention_intro' => 'คุณถูกกล่าวถึงในความคิดเห็นใน :appName:', + + 'detail_page_name' => 'ชื่อหน้า:', + 'detail_page_path' => 'พาธหน้า:', + 'detail_commenter' => 'ผู้แสดงความคิดเห็น:', + 'detail_comment' => 'ความคิดเห็น:', + 'detail_created_by' => 'สร้างโดย:', + 'detail_updated_by' => 'แก้ไขโดย:', + + 'action_view_comment' => 'ดูความคิดเห็น', + 'action_view_page' => 'ดูหน้า', + + 'footer_reason' => 'คุณได้รับการแจ้งเตือนนี้เพราะ :link ครอบคลุมกิจกรรมประเภทนี้สำหรับรายการนี้', + 'footer_reason_link' => 'การตั้งค่าการแจ้งเตือนของคุณ', +]; diff --git a/lang/th/pagination.php b/lang/th/pagination.php new file mode 100644 index 000000000..c29e7d0d9 --- /dev/null +++ b/lang/th/pagination.php @@ -0,0 +1,12 @@ + '« ก่อนหน้า', + 'next' => 'ถัดไป »', + +]; diff --git a/lang/th/passwords.php b/lang/th/passwords.php new file mode 100644 index 000000000..3429c7763 --- /dev/null +++ b/lang/th/passwords.php @@ -0,0 +1,15 @@ + 'รหัสผ่านต้องมีอย่างน้อยแปดตัวอักษรและต้องตรงกับการยืนยัน', + 'user' => "ไม่พบผู้ใช้ที่ใช้ที่อยู่อีเมลนี้", + 'token' => 'โทเค็นรีเซ็ตรหัสผ่านไม่ถูกต้องสำหรับที่อยู่อีเมลนี้', + 'sent' => 'ส่งลิงก์รีเซ็ตรหัสผ่านไปยังอีเมลของคุณแล้ว!', + 'reset' => 'รีเซ็ตรหัสผ่านของคุณแล้ว!', + +]; diff --git a/lang/th/preferences.php b/lang/th/preferences.php new file mode 100644 index 000000000..56d57fc26 --- /dev/null +++ b/lang/th/preferences.php @@ -0,0 +1,52 @@ + 'บัญชีของฉัน', + + 'shortcuts' => 'แป้นพิมพ์ลัด', + 'shortcuts_interface' => 'การตั้งค่าแป้นพิมพ์ลัด UI', + 'shortcuts_toggle_desc' => 'คุณสามารถเปิดหรือปิดแป้นพิมพ์ลัดของระบบที่ใช้สำหรับการนำทางและการดำเนินการได้ที่นี่', + 'shortcuts_customize_desc' => 'คุณสามารถปรับแต่งแป้นพิมพ์ลัดแต่ละรายการด้านล่างได้ เพียงกดคีย์ผสมที่ต้องการหลังจากเลือกช่องป้อนข้อมูลสำหรับแป้นลัดนั้น', + 'shortcuts_toggle_label' => 'เปิดใช้งานแป้นพิมพ์ลัด', + 'shortcuts_section_navigation' => 'การนำทาง', + 'shortcuts_section_actions' => 'การดำเนินการทั่วไป', + 'shortcuts_save' => 'บันทึกแป้นพิมพ์ลัด', + 'shortcuts_overlay_desc' => 'หมายเหตุ: เมื่อเปิดใช้งานแป้นพิมพ์ลัด จะมีแผงช่วยเหลือที่เข้าถึงได้โดยกด "?" ซึ่งจะแสดงแป้นพิมพ์ลัดที่ใช้ได้สำหรับการดำเนินการที่แสดงอยู่บนหน้าจอ', + 'shortcuts_update_success' => 'อัปเดตการตั้งค่าแป้นพิมพ์ลัดแล้ว!', + 'shortcuts_overview_desc' => 'จัดการแป้นพิมพ์ลัดที่ใช้นำทางในส่วนติดต่อผู้ใช้ของระบบ', + + 'notifications' => 'การตั้งค่าการแจ้งเตือน', + 'notifications_desc' => 'ควบคุมการแจ้งเตือนทางอีเมลที่คุณได้รับเมื่อมีกิจกรรมบางอย่างในระบบ', + 'notifications_opt_own_page_changes' => 'แจ้งเตือนเมื่อมีการเปลี่ยนแปลงหน้าที่ฉันเป็นเจ้าของ', + 'notifications_opt_own_page_comments' => 'แจ้งเตือนเมื่อมีความคิดเห็นในหน้าที่ฉันเป็นเจ้าของ', + 'notifications_opt_comment_mentions' => 'แจ้งเตือนเมื่อฉันถูกกล่าวถึงในความคิดเห็น', + 'notifications_opt_comment_replies' => 'แจ้งเตือนเมื่อมีการตอบกลับความคิดเห็นของฉัน', + 'notifications_save' => 'บันทึกการตั้งค่า', + 'notifications_update_success' => 'อัปเดตการตั้งค่าการแจ้งเตือนแล้ว!', + 'notifications_watched' => 'รายการที่ติดตามและไม่สนใจ', + 'notifications_watched_desc' => 'รายการด้านล่างมีการตั้งค่าการติดตามแบบกำหนดเอง หากต้องการอัปเดตการตั้งค่า ให้ดูรายการนั้นแล้วค้นหาตัวเลือกการติดตามในแถบด้านข้าง', + + 'auth' => 'การเข้าถึงและความปลอดภัย', + 'auth_change_password' => 'เปลี่ยนรหัสผ่าน', + 'auth_change_password_desc' => 'เปลี่ยนรหัสผ่านที่ใช้เข้าสู่ระบบ ต้องมีความยาวอย่างน้อย 8 ตัวอักษร', + 'auth_change_password_success' => 'อัปเดตรหัสผ่านแล้ว!', + + 'profile' => 'รายละเอียดโปรไฟล์', + 'profile_desc' => 'จัดการรายละเอียดบัญชีที่แสดงตัวตนต่อผู้ใช้อื่น รวมถึงรายละเอียดที่ใช้สำหรับการสื่อสารและการปรับแต่งระบบ', + 'profile_view_public' => 'ดูโปรไฟล์สาธารณะ', + 'profile_name_desc' => 'กำหนดชื่อที่แสดงซึ่งจะมองเห็นได้โดยผู้ใช้อื่นในระบบผ่านกิจกรรมที่คุณดำเนินการและเนื้อหาที่คุณเป็นเจ้าของ', + 'profile_email_desc' => 'อีเมลนี้จะใช้สำหรับการแจ้งเตือนและการเข้าถึงระบบขึ้นอยู่กับการยืนยันตัวตนที่ใช้งานอยู่', + 'profile_email_no_permission' => 'ขออภัย คุณไม่มีสิทธิ์เปลี่ยนที่อยู่อีเมล หากต้องการเปลี่ยน กรุณาติดต่อผู้ดูแลระบบ', + 'profile_avatar_desc' => 'เลือกรูปภาพที่จะใช้แสดงตัวตนต่อผู้อื่นในระบบ รูปภาพควรเป็นรูปสี่เหลี่ยมจัตุรัสขนาดประมาณ 256px', + 'profile_admin_options' => 'ตัวเลือกสำหรับผู้ดูแลระบบ', + 'profile_admin_options_desc' => 'ตัวเลือกระดับผู้ดูแลระบบเพิ่มเติม เช่น การจัดการการกำหนดบทบาท สามารถพบได้ในพื้นที่ "การตั้งค่า > ผู้ใช้" ของแอปพลิเคชัน', + + 'delete_account' => 'ลบบัญชี', + 'delete_my_account' => 'ลบบัญชีของฉัน', + 'delete_my_account_desc' => 'การดำเนินการนี้จะลบบัญชีผู้ใช้ของคุณออกจากระบบทั้งหมด คุณจะไม่สามารถกู้คืนบัญชีหรือยกเลิกการดำเนินการนี้ได้ เนื้อหาที่คุณสร้าง เช่น หน้าที่สร้างและรูปภาพที่อัปโหลด จะยังคงอยู่', + 'delete_my_account_warning' => 'คุณแน่ใจหรือไม่ว่าต้องการลบบัญชีของคุณ?', +]; diff --git a/lang/th/settings.php b/lang/th/settings.php new file mode 100644 index 000000000..1b90d2a71 --- /dev/null +++ b/lang/th/settings.php @@ -0,0 +1,375 @@ + 'การตั้งค่า', + 'settings_save' => 'บันทึกการตั้งค่า', + 'system_version' => 'เวอร์ชันระบบ', + 'categories' => 'หมวดหมู่', + + // App Settings + 'app_customization' => 'การปรับแต่ง', + 'app_features_security' => 'คุณสมบัติและความปลอดภัย', + 'app_name' => 'ชื่อแอปพลิเคชัน', + 'app_name_desc' => 'ชื่อนี้จะแสดงในส่วนหัวและอีเมลที่ส่งโดยระบบ', + 'app_name_header' => 'แสดงชื่อในส่วนหัว', + 'app_public_access' => 'การเข้าถึงสาธารณะ', + 'app_public_access_desc' => 'การเปิดใช้งานตัวเลือกนี้จะอนุญาตให้ผู้เยี่ยมชมที่ไม่ได้เข้าสู่ระบบสามารถเข้าถึงเนื้อหาใน BookStack ของคุณได้', + 'app_public_access_desc_guest' => 'การเข้าถึงของผู้เยี่ยมชมสาธารณะสามารถควบคุมได้ผ่านผู้ใช้ "Guest"', + 'app_public_access_toggle' => 'อนุญาตการเข้าถึงสาธารณะ', + 'app_public_viewing' => 'อนุญาตให้ดูแบบสาธารณะ?', + 'app_secure_images' => 'การอัปโหลดรูปภาพแบบความปลอดภัยสูง', + 'app_secure_images_toggle' => 'เปิดใช้งานการอัปโหลดรูปภาพแบบความปลอดภัยสูง', + 'app_secure_images_desc' => 'เพื่อประสิทธิภาพ รูปภาพทั้งหมดเป็นสาธารณะ ตัวเลือกนี้จะเพิ่มสตริงสุ่มที่คาดเดาได้ยากหน้า URL รูปภาพ ตรวจสอบให้แน่ใจว่าไม่ได้เปิดใช้งานการแสดงรายการไดเรกทอรีเพื่อป้องกันการเข้าถึงที่ง่าย', + 'app_default_editor' => 'ตัวแก้ไขหน้าเริ่มต้น', + 'app_default_editor_desc' => 'เลือกตัวแก้ไขที่จะใช้โดยค่าเริ่มต้นเมื่อแก้ไขหน้าใหม่ ซึ่งสามารถแทนที่ได้ในระดับหน้าเมื่อมีสิทธิ์อนุญาต', + 'app_custom_html' => 'เนื้อหา HTML Head แบบกำหนดเอง', + 'app_custom_html_desc' => 'เนื้อหาที่เพิ่มที่นี่จะถูกแทรกที่ด้านล่างของส่วน ของทุกหน้า มีประโยชน์สำหรับการแทนที่สไตล์หรือเพิ่มโค้ด Analytics', + 'app_custom_html_disabled_notice' => 'เนื้อหา HTML head แบบกำหนดเองถูกปิดใช้งานในหน้าการตั้งค่านี้เพื่อให้สามารถเปลี่ยนแปลงที่ทำให้ใช้งานไม่ได้กลับคืนได้', + 'app_logo' => 'โลโก้แอปพลิเคชัน', + 'app_logo_desc' => 'ใช้ในแถบส่วนหัวของแอปพลิเคชัน รูปภาพควรมีความสูง 86px รูปภาพขนาดใหญ่จะถูกย่อขนาด', + 'app_icon' => 'ไอคอนแอปพลิเคชัน', + 'app_icon_desc' => 'ไอคอนนี้ใช้สำหรับแท็บเบราว์เซอร์และไอคอนทางลัด ควรเป็นรูปภาพ PNG สี่เหลี่ยมขนาด 256px', + 'app_homepage' => 'หน้าแรกของแอปพลิเคชัน', + 'app_homepage_desc' => 'เลือกมุมมองที่จะแสดงบนหน้าแรกแทนมุมมองเริ่มต้น สิทธิ์หน้าจะถูกละเว้นสำหรับหน้าที่เลือก', + 'app_homepage_select' => 'เลือกหน้า', + 'app_footer_links' => 'ลิงก์ส่วนท้าย', + 'app_footer_links_desc' => 'เพิ่มลิงก์เพื่อแสดงในส่วนท้ายของเว็บไซต์ จะแสดงที่ด้านล่างของส่วนใหญ่ของหน้า รวมถึงหน้าที่ไม่ต้องเข้าสู่ระบบ คุณสามารถใช้ป้ายชื่อ "trans::" เพื่อใช้การแปลที่ระบบกำหนดไว้ เช่น "trans::common.privacy_policy" จะให้ข้อความ "นโยบายความเป็นส่วนตัว"', + 'app_footer_links_label' => 'ป้ายชื่อลิงก์', + 'app_footer_links_url' => 'URL ลิงก์', + 'app_footer_links_add' => 'เพิ่มลิงก์ส่วนท้าย', + 'app_disable_comments' => 'ปิดใช้งานความคิดเห็น', + 'app_disable_comments_toggle' => 'ปิดใช้งานความคิดเห็น', + 'app_disable_comments_desc' => 'ปิดใช้งานความคิดเห็นในทุกหน้าในแอปพลิเคชัน
    ความคิดเห็นที่มีอยู่จะไม่ถูกแสดง', + + // Color settings + 'color_scheme' => 'โครงสีของแอปพลิเคชัน', + 'color_scheme_desc' => 'ตั้งค่าสีที่จะใช้ในส่วนติดต่อผู้ใช้ของแอปพลิเคชัน สามารถกำหนดค่าสีแยกกันสำหรับโหมดมืดและโหมดสว่างเพื่อให้เหมาะกับธีมและรับประกันการอ่านง่าย', + 'ui_colors_desc' => 'ตั้งค่าสีหลักและสีลิงก์เริ่มต้นของแอปพลิเคชัน สีหลักใช้สำหรับแบนเนอร์ส่วนหัว ปุ่ม และการตกแต่งส่วนติดต่อ สีลิงก์เริ่มต้นใช้สำหรับลิงก์และการดำเนินการที่เป็นข้อความ', + 'app_color' => 'สีหลัก', + 'link_color' => 'สีลิงก์เริ่มต้น', + 'content_colors_desc' => 'ตั้งค่าสีสำหรับองค์ประกอบทั้งหมดในลำดับชั้นการจัดระเบียบหน้า แนะนำให้เลือกสีที่มีความสว่างใกล้เคียงกับสีเริ่มต้นเพื่อการอ่านง่าย', + 'bookshelf_color' => 'สีชั้นวาง', + 'book_color' => 'สีหนังสือ', + 'chapter_color' => 'สีบท', + 'page_color' => 'สีหน้า', + 'page_draft_color' => 'สีร่างหน้า', + + // Registration Settings + 'reg_settings' => 'การลงทะเบียน', + 'reg_enable' => 'เปิดใช้งานการลงทะเบียน', + 'reg_enable_toggle' => 'เปิดใช้งานการลงทะเบียน', + 'reg_enable_desc' => 'เมื่อเปิดใช้งานการลงทะเบียน ผู้ใช้จะสามารถสมัครเป็นผู้ใช้แอปพลิเคชันได้ด้วยตนเอง เมื่อลงทะเบียนแล้วจะได้รับบทบาทผู้ใช้เริ่มต้นหนึ่งบทบาท', + 'reg_default_role' => 'บทบาทผู้ใช้เริ่มต้นหลังการลงทะเบียน', + 'reg_enable_external_warning' => 'ตัวเลือกด้านบนจะถูกละเว้นเมื่อการยืนยันตัวตน LDAP หรือ SAML ภายนอกเปิดใช้งานอยู่ บัญชีผู้ใช้สำหรับสมาชิกที่ไม่มีอยู่จะถูกสร้างอัตโนมัติหากการยืนยันตัวตนกับระบบภายนอกที่ใช้งานอยู่สำเร็จ', + 'reg_email_confirmation' => 'การยืนยันอีเมล', + 'reg_email_confirmation_toggle' => 'ต้องยืนยันอีเมล', + 'reg_confirm_email_desc' => 'หากใช้การจำกัดโดเมน จะต้องยืนยันอีเมลและตัวเลือกนี้จะถูกละเว้น', + 'reg_confirm_restrict_domain' => 'การจำกัดโดเมน', + 'reg_confirm_restrict_domain_desc' => 'กรอกรายการโดเมนอีเมลที่คั่นด้วยเครื่องหมายจุลภาคที่ต้องการจำกัดการลงทะเบียน ผู้ใช้จะได้รับอีเมลเพื่อยืนยันที่อยู่ก่อนที่จะสามารถโต้ตอบกับแอปพลิเคชัน
    ผู้ใช้จะสามารถเปลี่ยนที่อยู่อีเมลได้หลังจากลงทะเบียนสำเร็จ', + 'reg_confirm_restrict_domain_placeholder' => 'ไม่มีการจำกัด', + + // Sorting Settings + 'sorting' => 'รายการและการจัดเรียง', + 'sorting_book_default' => 'กฎการจัดเรียงหนังสือเริ่มต้น', + 'sorting_book_default_desc' => 'เลือกกฎการจัดเรียงเริ่มต้นที่จะใช้กับหนังสือใหม่ ไม่มีผลกับหนังสือที่มีอยู่แล้ว และสามารถแทนที่ได้ต่อหนังสือ', + 'sorting_rules' => 'กฎการจัดเรียง', + 'sorting_rules_desc' => 'นี่คือการดำเนินการจัดเรียงที่กำหนดไว้ล่วงหน้าซึ่งสามารถนำไปใช้กับเนื้อหาในระบบ', + 'sort_rule_assigned_to_x_books' => 'กำหนดให้ :count หนังสือ', + 'sort_rule_create' => 'สร้างกฎการจัดเรียง', + 'sort_rule_edit' => 'แก้ไขกฎการจัดเรียง', + 'sort_rule_delete' => 'ลบกฎการจัดเรียง', + 'sort_rule_delete_desc' => 'ลบกฎการจัดเรียงนี้ออกจากระบบ หนังสือที่ใช้กฎนี้จะกลับไปใช้การจัดเรียงแบบกำหนดเอง', + 'sort_rule_delete_warn_books' => 'กฎการจัดเรียงนี้ใช้งานอยู่กับ :count เล่ม คุณแน่ใจหรือไม่ว่าต้องการลบ?', + 'sort_rule_delete_warn_default' => 'กฎการจัดเรียงนี้ใช้งานอยู่เป็นค่าเริ่มต้นสำหรับหนังสือ คุณแน่ใจหรือไม่ว่าต้องการลบ?', + 'sort_rule_details' => 'รายละเอียดกฎการจัดเรียง', + 'sort_rule_details_desc' => 'ตั้งชื่อสำหรับกฎการจัดเรียงนี้ ซึ่งจะปรากฏในรายการเมื่อผู้ใช้เลือกการจัดเรียง', + 'sort_rule_operations' => 'การดำเนินการจัดเรียง', + 'sort_rule_operations_desc' => 'กำหนดการดำเนินการจัดเรียงโดยย้ายจากรายการที่มีให้ เมื่อใช้งาน การดำเนินการจะถูกนำไปใช้ตามลำดับจากบนลงล่าง การเปลี่ยนแปลงจะถูกนำไปใช้กับหนังสือที่กำหนดทั้งหมดเมื่อบันทึก', + 'sort_rule_available_operations' => 'การดำเนินการที่มีให้', + 'sort_rule_available_operations_empty' => 'ไม่มีการดำเนินการเหลือ', + 'sort_rule_configured_operations' => 'การดำเนินการที่กำหนดค่า', + 'sort_rule_configured_operations_empty' => 'ลาก/เพิ่มการดำเนินการจากรายการ "การดำเนินการที่มีให้"', + 'sort_rule_op_asc' => '(น้อยไปมาก)', + 'sort_rule_op_desc' => '(มากไปน้อย)', + 'sort_rule_op_name' => 'ชื่อ - ตามตัวอักษร', + 'sort_rule_op_name_numeric' => 'ชื่อ - ตามตัวเลข', + 'sort_rule_op_created_date' => 'วันที่สร้าง', + 'sort_rule_op_updated_date' => 'วันที่แก้ไข', + 'sort_rule_op_chapters_first' => 'บทอยู่ก่อน', + 'sort_rule_op_chapters_last' => 'บทอยู่หลัง', + 'sorting_page_limits' => 'ขีดจำกัดการแสดงผลต่อหน้า', + 'sorting_page_limits_desc' => 'ตั้งค่าจำนวนรายการที่จะแสดงต่อหน้าในรายการต่างๆ ในระบบ โดยทั่วไปจำนวนน้อยจะมีประสิทธิภาพดีกว่า ขณะที่จำนวนมากจะลดความจำเป็นในการคลิกผ่านหลายหน้า แนะนำให้ใช้ทวีคูณของ 6', + + // Maintenance settings + 'maint' => 'การบำรุงรักษา', + 'maint_image_cleanup' => 'ล้างรูปภาพ', + 'maint_image_cleanup_desc' => 'สแกนเนื้อหาหน้าและการแก้ไขเพื่อตรวจสอบว่ารูปภาพและภาพวาดใดกำลังใช้งานอยู่และรูปภาพใดซ้ำซ้อน ตรวจสอบให้แน่ใจว่าสร้างข้อมูลสำรองฐานข้อมูลและรูปภาพฉบับสมบูรณ์ก่อนรัน', + 'maint_delete_images_only_in_revisions' => 'ลบรูปภาพที่มีเฉพาะในการแก้ไขหน้าเก่าด้วย', + 'maint_image_cleanup_run' => 'รันการล้างข้อมูล', + '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_mail_subject' => 'อีเมลทดสอบ', + 'maint_send_test_email_mail_greeting' => 'การส่งอีเมลดูเหมือนจะทำงานได้!', + 'maint_send_test_email_mail_text' => 'ยินดีด้วย! เนื่องจากคุณได้รับการแจ้งเตือนทางอีเมลนี้ การตั้งค่าอีเมลของคุณดูเหมือนจะได้รับการกำหนดค่าอย่างถูกต้อง', + 'maint_recycle_bin_desc' => 'ชั้นวาง หนังสือ บท และหน้าที่ถูกลบจะถูกส่งไปยังถังรีไซเคิลเพื่อให้สามารถกู้คืนหรือลบถาวรได้ รายการเก่าในถังรีไซเคิลอาจถูกลบโดยอัตโนมัติหลังจากสักพักขึ้นอยู่กับการกำหนดค่าระบบ', + 'maint_recycle_bin_open' => 'เปิดถังรีไซเคิล', + 'maint_regen_references' => 'สร้างการอ้างอิงใหม่', + 'maint_regen_references_desc' => 'การดำเนินการนี้จะสร้างดัชนีการอ้างอิงข้ามรายการในฐานข้อมูลใหม่ โดยปกติจะจัดการโดยอัตโนมัติ แต่การดำเนินการนี้มีประโยชน์สำหรับการสร้างดัชนีเนื้อหาเก่าหรือเนื้อหาที่เพิ่มผ่านวิธีที่ไม่เป็นทางการ', + 'maint_regen_references_success' => 'สร้างดัชนีการอ้างอิงใหม่แล้ว!', + 'maint_timeout_command_note' => 'หมายเหตุ: การดำเนินการนี้อาจใช้เวลานาน ซึ่งอาจทำให้เกิดปัญหา timeout ในบางสภาพแวดล้อมเว็บ ทางเลือกหนึ่งคือดำเนินการผ่านคำสั่ง terminal', + + // Recycle Bin + 'recycle_bin' => 'ถังรีไซเคิล', + 'recycle_bin_desc' => 'คุณสามารถกู้คืนรายการที่ถูกลบหรือเลือกลบออกจากระบบถาวรได้ที่นี่ รายการนี้ไม่ได้กรองต่างจากรายการกิจกรรมที่คล้ายกันในระบบซึ่งมีการกรองสิทธิ์', + 'recycle_bin_deleted_item' => 'รายการที่ถูกลบ', + 'recycle_bin_deleted_parent' => 'รายการแม่', + 'recycle_bin_deleted_by' => 'ลบโดย', + 'recycle_bin_deleted_at' => 'เวลาที่ลบ', + 'recycle_bin_permanently_delete' => 'ลบถาวร', + 'recycle_bin_restore' => 'กู้คืน', + 'recycle_bin_contents_empty' => 'ถังรีไซเคิลว่างเปล่า', + 'recycle_bin_empty' => 'ล้างถังรีไซเคิล', + 'recycle_bin_empty_confirm' => 'การดำเนินการนี้จะทำลายรายการทั้งหมดในถังรีไซเคิลอย่างถาวร รวมถึงเนื้อหาที่อยู่ในแต่ละรายการ คุณแน่ใจหรือไม่ว่าต้องการล้างถังรีไซเคิล?', + 'recycle_bin_destroy_confirm' => 'การดำเนินการนี้จะลบรายการนี้ออกจากระบบอย่างถาวร พร้อมกับองค์ประกอบลูกที่แสดงด้านล่าง และคุณจะไม่สามารถกู้คืนเนื้อหานี้ได้ คุณแน่ใจหรือไม่?', + 'recycle_bin_destroy_list' => 'รายการที่จะถูกทำลาย', + 'recycle_bin_restore_list' => 'รายการที่จะถูกกู้คืน', + 'recycle_bin_restore_confirm' => 'การดำเนินการนี้จะกู้คืนรายการที่ถูกลบ รวมถึงองค์ประกอบลูก ไปยังตำแหน่งเดิม หากตำแหน่งเดิมถูกลบไปแล้วและอยู่ในถังรีไซเคิล จะต้องกู้คืนรายการแม่ด้วย', + 'recycle_bin_restore_deleted_parent' => 'รายการแม่ของรายการนี้ถูกลบเช่นกัน จะยังคงถูกลบจนกว่าจะกู้คืนรายการแม่', + 'recycle_bin_restore_parent' => 'กู้คืนรายการแม่', + 'recycle_bin_destroy_notification' => 'ลบรายการทั้งหมด :count รายการจากถังรีไซเคิล', + 'recycle_bin_restore_notification' => 'กู้คืนรายการทั้งหมด :count รายการจากถังรีไซเคิล', + + // Audit Log + 'audit' => 'บันทึกการตรวจสอบ', + 'audit_desc' => 'บันทึกการตรวจสอบนี้แสดงรายการกิจกรรมที่ติดตามในระบบ รายการนี้ไม่ได้กรองต่างจากรายการกิจกรรมที่คล้ายกันในระบบซึ่งมีการกรองสิทธิ์', + 'audit_event_filter' => 'ตัวกรองกิจกรรม', + 'audit_event_filter_no_filter' => 'ไม่มีตัวกรอง', + 'audit_deleted_item' => 'รายการที่ถูกลบ', + 'audit_deleted_item_name' => 'ชื่อ: :name', + 'audit_table_user' => 'ผู้ใช้', + 'audit_table_event' => 'กิจกรรม', + 'audit_table_related' => 'รายการหรือรายละเอียดที่เกี่ยวข้อง', + 'audit_table_ip' => 'IP Address', + 'audit_table_date' => 'วันที่กิจกรรม', + 'audit_date_from' => 'ช่วงวันที่ตั้งแต่', + 'audit_date_to' => 'ช่วงวันที่ถึง', + + // Role Settings + 'roles' => 'บทบาท', + 'role_user_roles' => 'บทบาทผู้ใช้', + 'roles_index_desc' => 'บทบาทใช้สำหรับจัดกลุ่มผู้ใช้และให้สิทธิ์ระบบแก่สมาชิก เมื่อผู้ใช้เป็นสมาชิกของหลายบทบาท สิทธิ์ที่ได้รับจะสะสมกัน และผู้ใช้จะได้รับความสามารถทั้งหมด', + 'roles_x_users_assigned' => 'กำหนด :count ผู้ใช้', + 'roles_x_permissions_provided' => ':count สิทธิ์', + 'roles_assigned_users' => 'ผู้ใช้ที่กำหนด', + 'roles_permissions_provided' => 'สิทธิ์ที่ให้', + 'role_create' => 'สร้างบทบาทใหม่', + 'role_delete' => 'ลบบทบาท', + 'role_delete_confirm' => 'การดำเนินการนี้จะลบบทบาทชื่อ \':roleName\'', + 'role_delete_users_assigned' => 'บทบาทนี้มีผู้ใช้ :userCount คนที่กำหนดไว้ หากต้องการย้ายผู้ใช้จากบทบาทนี้ให้เลือกบทบาทใหม่ด้านล่าง', + 'role_delete_no_migration' => "ไม่ต้องย้ายผู้ใช้", + 'role_delete_sure' => 'คุณแน่ใจหรือไม่ว่าต้องการลบบทบาทนี้?', + 'role_edit' => 'แก้ไขบทบาท', + 'role_details' => 'รายละเอียดบทบาท', + 'role_name' => 'ชื่อบทบาท', + 'role_desc' => 'คำอธิบายสั้นของบทบาท', + 'role_mfa_enforced' => 'ต้องใช้การยืนยันตัวตนแบบหลายขั้นตอน', + 'role_external_auth_id' => 'ID การยืนยันตัวตนภายนอก', + 'role_system' => 'สิทธิ์ระบบ', + 'role_manage_users' => 'จัดการผู้ใช้', + 'role_manage_roles' => 'จัดการบทบาทและสิทธิ์บทบาท', + 'role_manage_entity_permissions' => 'จัดการสิทธิ์หนังสือ บท และหน้าทั้งหมด', + 'role_manage_own_entity_permissions' => 'จัดการสิทธิ์บนหนังสือ บท และหน้าของตัวเอง', + 'role_manage_page_templates' => 'จัดการแม่แบบหน้า', + 'role_access_api' => 'เข้าถึง API ระบบ', + 'role_manage_settings' => 'จัดการการตั้งค่าแอปพลิเคชัน', + 'role_export_content' => 'ส่งออกเนื้อหา', + 'role_import_content' => 'นำเข้าเนื้อหา', + 'role_editor_change' => 'เปลี่ยนตัวแก้ไขหน้า', + 'role_notifications' => 'รับและจัดการการแจ้งเตือน', + 'role_permission_note_users_and_roles' => 'สิทธิ์เหล่านี้จะให้การมองเห็นและการค้นหาผู้ใช้และบทบาทในระบบด้วยในทางเทคนิค', + 'role_asset' => 'สิทธิ์ Asset', + 'roles_system_warning' => 'โปรดทราบว่าการเข้าถึงสิทธิ์ใดๆ ในสามสิทธิ์ข้างต้นอาจอนุญาตให้ผู้ใช้เปลี่ยนแปลงสิทธิ์ของตัวเองหรือของผู้อื่นในระบบ กรุณากำหนดบทบาทที่มีสิทธิ์เหล่านี้เฉพาะกับผู้ใช้ที่เชื่อถือได้เท่านั้น', + 'role_asset_desc' => 'สิทธิ์เหล่านี้ควบคุมการเข้าถึงเริ่มต้นสำหรับ asset ในระบบ สิทธิ์บนหนังสือ บท และหน้าจะแทนที่สิทธิ์เหล่านี้', + 'role_asset_admins' => 'ผู้ดูแลระบบได้รับสิทธิ์เข้าถึงเนื้อหาทั้งหมดโดยอัตโนมัติ แต่ตัวเลือกเหล่านี้อาจแสดงหรือซ่อนตัวเลือก UI', + 'role_asset_image_view_note' => 'เกี่ยวข้องกับการมองเห็นภายในตัวจัดการรูปภาพ การเข้าถึงไฟล์รูปภาพที่อัปโหลดจริงจะขึ้นอยู่กับตัวเลือกการจัดเก็บรูปภาพของระบบ', + 'role_asset_users_note' => 'สิทธิ์เหล่านี้จะให้การมองเห็นและการค้นหาผู้ใช้ในระบบด้วยในทางเทคนิค', + 'role_all' => 'ทั้งหมด', + 'role_own' => 'ของตัวเอง', + 'role_controlled_by_asset' => 'ควบคุมโดย asset ที่อัปโหลดไปยัง', + 'role_controlled_by_page_delete' => 'ควบคุมโดยสิทธิ์ลบหน้า', + 'role_save' => 'บันทึกบทบาท', + 'role_users' => 'ผู้ใช้ในบทบาทนี้', + 'role_users_none' => 'ยังไม่มีผู้ใช้ที่กำหนดให้บทบาทนี้', + + // Users + 'users' => 'ผู้ใช้', + 'users_index_desc' => 'สร้างและจัดการบัญชีผู้ใช้แต่ละรายในระบบ บัญชีผู้ใช้ใช้สำหรับเข้าสู่ระบบและระบุที่มาของเนื้อหาและกิจกรรม สิทธิ์การเข้าถึงหลักอิงตามบทบาท แต่ความเป็นเจ้าของเนื้อหาและปัจจัยอื่นๆ อาจส่งผลต่อสิทธิ์และการเข้าถึงด้วย', + 'user_profile' => 'โปรไฟล์ผู้ใช้', + 'users_add_new' => 'เพิ่มผู้ใช้ใหม่', + 'users_search' => 'ค้นหาผู้ใช้', + 'users_latest_activity' => 'กิจกรรมล่าสุด', + 'users_details' => 'รายละเอียดผู้ใช้', + 'users_details_desc' => 'ตั้งชื่อที่แสดงและที่อยู่อีเมลสำหรับผู้ใช้นี้ ที่อยู่อีเมลจะใช้สำหรับเข้าสู่ระบบแอปพลิเคชัน', + 'users_details_desc_no_email' => 'ตั้งชื่อที่แสดงสำหรับผู้ใช้นี้เพื่อให้ผู้อื่นรู้จัก', + 'users_role' => 'บทบาทผู้ใช้', + 'users_role_desc' => 'เลือกบทบาทที่จะกำหนดให้ผู้ใช้นี้ หากผู้ใช้ถูกกำหนดให้หลายบทบาท สิทธิ์จากบทบาทเหล่านั้นจะสะสมกัน', + 'users_password' => 'รหัสผ่านผู้ใช้', + 'users_password_desc' => 'ตั้งรหัสผ่านสำหรับเข้าสู่ระบบ ต้องมีความยาวอย่างน้อย 8 ตัวอักษร', + 'users_send_invite_text' => 'คุณสามารถเลือกส่งอีเมลเชิญให้ผู้ใช้นี้เพื่อให้ตั้งรหัสผ่านของตัวเอง หรือตั้งรหัสผ่านให้เองก็ได้', + 'users_send_invite_option' => 'ส่งอีเมลเชิญผู้ใช้', + 'users_external_auth_id' => 'ID การยืนยันตัวตนภายนอก', + 'users_external_auth_id_desc' => 'เมื่อใช้ระบบยืนยันตัวตนภายนอก (เช่น SAML2, OIDC หรือ LDAP) นี่คือ ID ที่เชื่อมผู้ใช้ BookStack นี้กับบัญชีระบบยืนยันตัวตน สามารถละเว้นช่องนี้หากใช้การยืนยันตัวตนแบบอีเมลเริ่มต้น', + 'users_password_warning' => 'กรอกด้านล่างเฉพาะเมื่อต้องการเปลี่ยนรหัสผ่านสำหรับผู้ใช้นี้', + 'users_system_public' => 'ผู้ใช้นี้แทนผู้เยี่ยมชมที่เข้าถึง instance ของคุณ ไม่สามารถใช้เข้าสู่ระบบได้ แต่จะถูกกำหนดโดยอัตโนมัติ', + 'users_delete' => 'ลบผู้ใช้', + 'users_delete_named' => 'ลบผู้ใช้ :userName', + 'users_delete_warning' => 'การดำเนินการนี้จะลบผู้ใช้ชื่อ \':userName\' ออกจากระบบทั้งหมด', + 'users_delete_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการลบผู้ใช้นี้?', + 'users_migrate_ownership' => 'ย้ายความเป็นเจ้าของ', + 'users_migrate_ownership_desc' => 'เลือกผู้ใช้ที่นี่หากต้องการให้ผู้ใช้อื่นเป็นเจ้าของรายการทั้งหมดที่เป็นของผู้ใช้นี้ในปัจจุบัน', + 'users_none_selected' => 'ไม่มีผู้ใช้ที่เลือก', + 'users_edit' => 'แก้ไขผู้ใช้', + 'users_edit_profile' => 'แก้ไขโปรไฟล์', + 'users_avatar' => 'รูปโปรไฟล์ผู้ใช้', + 'users_avatar_desc' => 'เลือกรูปภาพเพื่อแสดงตัวผู้ใช้นี้ ควรเป็นรูปสี่เหลี่ยมขนาดประมาณ 256px', + 'users_preferred_language' => 'ภาษาที่ต้องการ', + 'users_preferred_language_desc' => 'ตัวเลือกนี้จะเปลี่ยนภาษาที่ใช้สำหรับส่วนติดต่อผู้ใช้ของแอปพลิเคชัน ไม่มีผลต่อเนื้อหาที่ผู้ใช้สร้าง', + 'users_social_accounts' => 'บัญชี Social', + 'users_social_accounts_desc' => 'ดูสถานะของบัญชี Social ที่เชื่อมต่อสำหรับผู้ใช้นี้ บัญชี Social สามารถใช้นอกเหนือจากระบบยืนยันตัวตนหลักสำหรับการเข้าถึงระบบ', + 'users_social_accounts_info' => 'คุณสามารถเชื่อมต่อบัญชีอื่นที่นี่เพื่อเข้าสู่ระบบที่เร็วและง่ายขึ้น การตัดการเชื่อมต่อบัญชีที่นี่ไม่ได้เพิกถอนสิทธิ์ที่ให้ไว้ก่อนหน้า กรุณาเพิกถอนสิทธิ์จากการตั้งค่าโปรไฟล์บนบัญชี Social ที่เชื่อมต่อ', + 'users_social_connect' => 'เชื่อมต่อบัญชี', + 'users_social_disconnect' => 'ตัดการเชื่อมต่อบัญชี', + 'users_social_status_connected' => 'เชื่อมต่อแล้ว', + 'users_social_status_disconnected' => 'ตัดการเชื่อมต่อแล้ว', + 'users_social_connected' => 'เชื่อมต่อบัญชี :socialAccount กับโปรไฟล์ของคุณสำเร็จแล้ว', + 'users_social_disconnected' => 'ตัดการเชื่อมต่อบัญชี :socialAccount จากโปรไฟล์ของคุณสำเร็จแล้ว', + 'users_api_tokens' => 'API Tokens', + 'users_api_tokens_desc' => 'สร้างและจัดการโทเค็นการเข้าถึงที่ใช้ยืนยันตัวตนกับ BookStack REST API สิทธิ์สำหรับ API จัดการผ่านผู้ใช้ที่โทเค็นนั้นสังกัด', + 'users_api_tokens_none' => 'ยังไม่มี API token ที่สร้างสำหรับผู้ใช้นี้', + 'users_api_tokens_create' => 'สร้าง Token', + 'users_api_tokens_expires' => 'หมดอายุ', + 'users_api_tokens_docs' => 'เอกสาร API', + 'users_mfa' => 'การยืนยันตัวตนแบบหลายขั้นตอน', + 'users_mfa_desc' => 'ตั้งค่าการยืนยันตัวตนแบบหลายขั้นตอนเป็นชั้นความปลอดภัยเพิ่มเติมสำหรับบัญชีผู้ใช้ของคุณ', + 'users_mfa_x_methods' => 'กำหนดค่า :count วิธี', + 'users_mfa_configure' => 'กำหนดค่าวิธี', + + // API Tokens + 'user_api_token_create' => 'สร้าง API Token', + 'user_api_token_name' => 'ชื่อ', + 'user_api_token_name_desc' => 'ตั้งชื่อที่อ่านง่ายสำหรับ token นี้เพื่อเตือนความจำเกี่ยวกับวัตถุประสงค์ในอนาคต', + 'user_api_token_expiry' => 'วันหมดอายุ', + 'user_api_token_expiry_desc' => 'ตั้งวันที่ token นี้หมดอายุ หลังจากวันนี้ คำขอที่ใช้ token นี้จะไม่ทำงานอีกต่อไป การเว้นช่องนี้ว่างจะตั้งวันหมดอายุเป็น 100 ปีในอนาคต', + 'user_api_token_create_secret_message' => 'ทันทีหลังจากสร้าง token นี้ "Token ID" และ "Token Secret" จะถูกสร้างและแสดง Secret จะแสดงเพียงครั้งเดียว ดังนั้นให้คัดลอกค่าไปยังที่ปลอดภัยก่อนดำเนินการต่อ', + 'user_api_token' => 'API Token', + 'user_api_token_id' => 'Token ID', + 'user_api_token_id_desc' => 'นี่คือตัวระบุที่สร้างโดยระบบซึ่งไม่สามารถแก้ไขได้สำหรับ token นี้ ซึ่งจะต้องระบุในคำขอ API', + 'user_api_token_secret' => 'Token Secret', + 'user_api_token_secret_desc' => 'นี่คือ secret ที่สร้างโดยระบบสำหรับ token นี้ซึ่งจะต้องระบุในคำขอ API จะแสดงเพียงครั้งเดียวนี้เท่านั้น ดังนั้นให้คัดลอกค่าไปยังที่ปลอดภัย', + 'user_api_token_created' => 'สร้าง Token เมื่อ :timeAgo', + 'user_api_token_updated' => 'อัปเดต Token เมื่อ :timeAgo', + 'user_api_token_delete' => 'ลบ Token', + 'user_api_token_delete_warning' => 'การดำเนินการนี้จะลบ API token ชื่อ \':tokenName\' ออกจากระบบทั้งหมด', + 'user_api_token_delete_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการลบ API token นี้?', + + // Webhooks + 'webhooks' => 'Webhooks', + 'webhooks_index_desc' => 'Webhooks เป็นวิธีส่งข้อมูลไปยัง URL ภายนอกเมื่อมีการดำเนินการและกิจกรรมบางอย่างในระบบ ซึ่งช่วยให้สามารถรวมกับแพลตฟอร์มภายนอก เช่น ระบบส่งข้อความหรือการแจ้งเตือน', + 'webhooks_x_trigger_events' => ':count กิจกรรมทริกเกอร์', + 'webhooks_create' => 'สร้าง Webhook ใหม่', + 'webhooks_none_created' => 'ยังไม่มี Webhook ที่สร้าง', + 'webhooks_edit' => 'แก้ไข Webhook', + 'webhooks_save' => 'บันทึก Webhook', + 'webhooks_details' => 'รายละเอียด Webhook', + 'webhooks_details_desc' => 'ระบุชื่อที่เป็นมิตรกับผู้ใช้และ POST endpoint เป็นตำแหน่งสำหรับส่งข้อมูล Webhook', + 'webhooks_events' => 'กิจกรรม Webhook', + 'webhooks_events_desc' => 'เลือกกิจกรรมทั้งหมดที่ควรทริกเกอร์ให้เรียก Webhook นี้', + 'webhooks_events_warning' => 'โปรดทราบว่ากิจกรรมเหล่านี้จะถูกทริกเกอร์สำหรับกิจกรรมที่เลือกทั้งหมด แม้จะมีสิทธิ์แบบกำหนดเองก็ตาม ตรวจสอบให้แน่ใจว่าการใช้ Webhook นี้จะไม่เปิดเผยเนื้อหาที่เป็นความลับ', + 'webhooks_events_all' => 'กิจกรรมทั้งหมดในระบบ', + 'webhooks_name' => 'ชื่อ Webhook', + 'webhooks_timeout' => 'หมดเวลาคำขอ Webhook (วินาที)', + 'webhooks_endpoint' => 'Endpoint ของ Webhook', + 'webhooks_active' => 'Webhook เปิดใช้งาน', + 'webhook_events_table_header' => 'กิจกรรม', + 'webhooks_delete' => 'ลบ Webhook', + 'webhooks_delete_warning' => 'การดำเนินการนี้จะลบ Webhook ชื่อ \':webhookName\' ออกจากระบบทั้งหมด', + 'webhooks_delete_confirm' => 'คุณแน่ใจหรือไม่ว่าต้องการลบ Webhook นี้?', + 'webhooks_format_example' => 'ตัวอย่างรูปแบบ Webhook', + 'webhooks_format_example_desc' => 'ข้อมูล Webhook ถูกส่งเป็นคำขอ POST ไปยัง endpoint ที่กำหนดค่าเป็น JSON ตามรูปแบบด้านล่าง คุณสมบัติ "related_item" และ "url" เป็นทางเลือกและขึ้นอยู่กับประเภทกิจกรรมที่ทริกเกอร์', + 'webhooks_status' => 'สถานะ Webhook', + 'webhooks_last_called' => 'เรียกล่าสุด:', + 'webhooks_last_errored' => 'เกิดข้อผิดพลาดล่าสุด:', + 'webhooks_last_error_message' => 'ข้อความข้อผิดพลาดล่าสุด:', + + // Licensing + 'licenses' => 'สัญญาอนุญาต', + 'licenses_desc' => 'หน้านี้แสดงข้อมูลสัญญาอนุญาตสำหรับ BookStack รวมถึงโปรเจกต์และไลบรารีที่ใช้ใน BookStack โปรเจกต์หลายรายการอาจใช้เฉพาะในบริบทการพัฒนา', + 'licenses_bookstack' => 'สัญญาอนุญาต BookStack', + 'licenses_php' => 'สัญญาอนุญาตไลบรารี PHP', + 'licenses_js' => 'สัญญาอนุญาตไลบรารี JavaScript', + 'licenses_other' => 'สัญญาอนุญาตอื่นๆ', + 'license_details' => 'รายละเอียดสัญญาอนุญาต', + + //! If editing translations files directly please ignore this in all + //! languages apart from en. Content will be auto-copied from en. + //!//////////////////////////////// + 'language_select' => [ + 'en' => 'English', + 'ar' => 'العربية', + 'bg' => 'Bǎlgarski', + 'bs' => 'Bosanski', + 'ca' => 'Català', + 'cs' => 'Česky', + 'cy' => 'Cymraeg', + 'da' => 'Dansk', + 'de' => 'Deutsch (Sie)', + 'de_informal' => 'Deutsch (Du)', + 'el' => 'ελληνικά', + 'es' => 'Español', + 'es_AR' => 'Español Argentina', + 'et' => 'Eesti keel', + 'eu' => 'Euskara', + 'fa' => 'فارسی', + 'fi' => 'Suomi', + 'fr' => 'Français', + 'he' => 'עברית', + 'hr' => 'Hrvatski', + 'hu' => 'Magyar', + 'id' => 'Bahasa Indonesia', + 'it' => 'Italian', + 'ja' => '日本語', + 'ko' => '한국어', + 'lt' => 'Lietuvių Kalba', + 'lv' => 'Latviešu Valoda', + 'nb' => 'Norsk (Bokmål)', + 'ne' => 'नेपाली', + 'nn' => 'Nynorsk', + 'nl' => 'Nederlands', + 'pl' => 'Polski', + 'pt' => 'Português', + 'pt_BR' => 'Português do Brasil', + 'ro' => 'Română', + 'ru' => 'Русский', + 'sk' => 'Slovensky', + 'sl' => 'Slovenščina', + 'sv' => 'Svenska', + 'tr' => 'Türkçe', + 'uk' => 'Українська', + 'uz' => 'O‘zbekcha', + 'vi' => 'Tiếng Việt', + 'zh_CN' => '简体中文', + 'zh_TW' => '繁體中文', + ], + //!//////////////////////////////// +]; diff --git a/lang/th/validation.php b/lang/th/validation.php new file mode 100644 index 000000000..347eeb36c --- /dev/null +++ b/lang/th/validation.php @@ -0,0 +1,123 @@ + 'ต้องยอมรับ :attribute', + 'active_url' => ':attribute ไม่ใช่ URL ที่ถูกต้อง', + 'after' => ':attribute ต้องเป็นวันที่หลังจาก :date', + 'alpha' => ':attribute ต้องมีเฉพาะตัวอักษรเท่านั้น', + 'alpha_dash' => ':attribute ต้องมีเฉพาะตัวอักษร ตัวเลข ขีดกลาง และขีดล่างเท่านั้น', + 'alpha_num' => ':attribute ต้องมีเฉพาะตัวอักษรและตัวเลขเท่านั้น', + 'array' => ':attribute ต้องเป็น array', + 'backup_codes' => 'รหัสที่ให้มาไม่ถูกต้องหรือถูกใช้ไปแล้ว', + 'before' => ':attribute ต้องเป็นวันที่ก่อน :date', + 'between' => [ + 'numeric' => ':attribute ต้องอยู่ระหว่าง :min ถึง :max', + 'file' => ':attribute ต้องมีขนาดระหว่าง :min ถึง :max กิโลไบต์', + 'string' => ':attribute ต้องมีความยาวระหว่าง :min ถึง :max ตัวอักษร', + 'array' => ':attribute ต้องมีระหว่าง :min ถึง :max รายการ', + ], + 'boolean' => 'ฟิลด์ :attribute ต้องเป็น true หรือ false', + 'confirmed' => 'การยืนยัน :attribute ไม่ตรงกัน', + 'date' => ':attribute ไม่ใช่วันที่ที่ถูกต้อง', + 'date_format' => ':attribute ไม่ตรงกับรูปแบบ :format', + 'different' => ':attribute และ :other ต้องแตกต่างกัน', + 'digits' => ':attribute ต้องมี :digits หลัก', + 'digits_between' => ':attribute ต้องมีระหว่าง :min ถึง :max หลัก', + 'email' => ':attribute ต้องเป็นที่อยู่อีเมลที่ถูกต้อง', + 'ends_with' => ':attribute ต้องลงท้ายด้วยหนึ่งในนี้: :values', + 'file' => ':attribute ต้องเป็นไฟล์ที่ถูกต้อง', + 'filled' => 'ฟิลด์ :attribute จำเป็นต้องกรอก', + 'gt' => [ + 'numeric' => ':attribute ต้องมากกว่า :value', + 'file' => ':attribute ต้องมากกว่า :value กิโลไบต์', + 'string' => ':attribute ต้องมีความยาวมากกว่า :value ตัวอักษร', + 'array' => ':attribute ต้องมีมากกว่า :value รายการ', + ], + 'gte' => [ + 'numeric' => ':attribute ต้องมากกว่าหรือเท่ากับ :value', + 'file' => ':attribute ต้องมากกว่าหรือเท่ากับ :value กิโลไบต์', + 'string' => ':attribute ต้องมีความยาวมากกว่าหรือเท่ากับ :value ตัวอักษร', + 'array' => ':attribute ต้องมี :value รายการขึ้นไป', + ], + 'exists' => ':attribute ที่เลือกไม่ถูกต้อง', + 'image' => ':attribute ต้องเป็นรูปภาพ', + 'image_extension' => ':attribute ต้องมีนามสกุลรูปภาพที่ถูกต้องและรองรับ', + 'in' => ':attribute ที่เลือกไม่ถูกต้อง', + 'integer' => ':attribute ต้องเป็นจำนวนเต็ม', + 'ip' => ':attribute ต้องเป็นที่อยู่ IP ที่ถูกต้อง', + 'ipv4' => ':attribute ต้องเป็นที่อยู่ IPv4 ที่ถูกต้อง', + 'ipv6' => ':attribute ต้องเป็นที่อยู่ IPv6 ที่ถูกต้อง', + 'json' => ':attribute ต้องเป็น JSON string ที่ถูกต้อง', + 'lt' => [ + 'numeric' => ':attribute ต้องน้อยกว่า :value', + 'file' => ':attribute ต้องน้อยกว่า :value กิโลไบต์', + 'string' => ':attribute ต้องมีความยาวน้อยกว่า :value ตัวอักษร', + 'array' => ':attribute ต้องมีน้อยกว่า :value รายการ', + ], + 'lte' => [ + 'numeric' => ':attribute ต้องน้อยกว่าหรือเท่ากับ :value', + 'file' => ':attribute ต้องน้อยกว่าหรือเท่ากับ :value กิโลไบต์', + 'string' => ':attribute ต้องมีความยาวน้อยกว่าหรือเท่ากับ :value ตัวอักษร', + 'array' => ':attribute ต้องไม่มีมากกว่า :value รายการ', + ], + 'max' => [ + 'numeric' => ':attribute ต้องไม่มากกว่า :max', + 'file' => ':attribute ต้องไม่มากกว่า :max กิโลไบต์', + 'string' => ':attribute ต้องไม่มากกว่า :max ตัวอักษร', + 'array' => ':attribute ต้องไม่มีมากกว่า :max รายการ', + ], + 'mimes' => ':attribute ต้องเป็นไฟล์ประเภท: :values', + 'min' => [ + 'numeric' => ':attribute ต้องอย่างน้อย :min', + 'file' => ':attribute ต้องอย่างน้อย :min กิโลไบต์', + 'string' => ':attribute ต้องมีความยาวอย่างน้อย :min ตัวอักษร', + 'array' => ':attribute ต้องมีอย่างน้อย :min รายการ', + ], + 'not_in' => ':attribute ที่เลือกไม่ถูกต้อง', + 'not_regex' => 'รูปแบบ :attribute ไม่ถูกต้อง', + 'numeric' => ':attribute ต้องเป็นตัวเลข', + 'regex' => 'รูปแบบ :attribute ไม่ถูกต้อง', + 'required' => 'ฟิลด์ :attribute จำเป็นต้องกรอก', + 'required_if' => 'ฟิลด์ :attribute จำเป็นต้องกรอกเมื่อ :other เป็น :value', + 'required_with' => 'ฟิลด์ :attribute จำเป็นต้องกรอกเมื่อมี :values', + 'required_with_all' => 'ฟิลด์ :attribute จำเป็นต้องกรอกเมื่อมี :values', + 'required_without' => 'ฟิลด์ :attribute จำเป็นต้องกรอกเมื่อไม่มี :values', + 'required_without_all' => 'ฟิลด์ :attribute จำเป็นต้องกรอกเมื่อไม่มีสิ่งใดใน :values', + 'same' => ':attribute และ :other ต้องตรงกัน', + 'safe_url' => 'ลิงก์ที่ให้มาอาจไม่ปลอดภัย', + 'size' => [ + 'numeric' => ':attribute ต้องเป็น :size', + 'file' => ':attribute ต้องมีขนาด :size กิโลไบต์', + 'string' => ':attribute ต้องมีความยาว :size ตัวอักษร', + 'array' => ':attribute ต้องมี :size รายการ', + ], + 'string' => ':attribute ต้องเป็น string', + 'timezone' => ':attribute ต้องเป็นเขตเวลาที่ถูกต้อง', + 'totp' => 'รหัสที่ให้มาไม่ถูกต้องหรือหมดอายุแล้ว', + 'unique' => ':attribute ถูกใช้งานแล้ว', + 'url' => 'รูปแบบ :attribute ไม่ถูกต้อง', + 'uploaded' => 'ไม่สามารถอัปโหลดไฟล์ได้ เซิร์ฟเวอร์อาจไม่รับไฟล์ขนาดนี้', + + 'zip_file' => ':attribute ต้องอ้างอิงถึงไฟล์ภายใน ZIP', + 'zip_file_size' => 'ไฟล์ :attribute ต้องไม่เกิน :size MB', + 'zip_file_mime' => ':attribute ต้องอ้างอิงถึงไฟล์ประเภท :validTypes แต่พบ :foundType', + 'zip_model_expected' => 'คาดหวัง data object แต่พบ ":type"', + 'zip_unique' => ':attribute ต้องไม่ซ้ำกันสำหรับประเภท object ภายใน ZIP', + + // Custom validation lines + 'custom' => [ + 'password-confirm' => [ + 'required_with' => 'จำเป็นต้องยืนยันรหัสผ่าน', + ], + ], + + // Custom validation attributes + 'attributes' => [], +]; diff --git a/lang/tk/entities.php b/lang/tk/entities.php index 74c50be3b..5501d2bc2 100644 --- a/lang/tk/entities.php +++ b/lang/tk/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', diff --git a/lang/tk/settings.php b/lang/tk/settings.php index c4d1eb136..3937c650f 100644 --- a/lang/tk/settings.php +++ b/lang/tk/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Save Role', 'role_users' => 'Users in this role', 'role_users_none' => 'No users are currently assigned to this role', diff --git a/lang/tr/entities.php b/lang/tr/entities.php index 39feb7e39..3d271ecb5 100644 --- a/lang/tr/entities.php +++ b/lang/tr/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => ':bookName Kitabını Sırala', 'books_sort_name' => 'İsme Göre Sırala', 'books_sort_created' => 'Oluşturulma Tarihine Göre Sırala', diff --git a/lang/tr/settings.php b/lang/tr/settings.php index a33d3e0ac..af8d2c649 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Hepsi', 'role_own' => 'Kendine Ait', 'role_controlled_by_asset' => 'Yüklendikleri varlık tarafından kontrol ediliyor', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Rolü Kaydet', 'role_users' => 'Bu roldeki kullanıcılar', 'role_users_none' => 'Bu role henüz bir kullanıcı atanmadı', diff --git a/lang/uk/entities.php b/lang/uk/entities.php index cf79b530c..6bf7b241e 100644 --- a/lang/uk/entities.php +++ b/lang/uk/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Перекладіть розділи та сторінки в межах книги, щоб реорганізувати вміст. Інші книги можна додати, що дозволяє легко переміщати глави та сторінки між книгами. При необхідності правило автоматичного сортування може бути встановлено для автоматичного сортування вмісту цієї книги при змінах.', 'books_sort_auto_sort' => 'Опція автоматичного сортування', 'books_sort_auto_sort_active' => 'Автосортування : :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Сортувати книгу :bookName', 'books_sort_name' => 'Сортувати за назвою', 'books_sort_created' => 'Сортувати за датою створення', diff --git a/lang/uk/settings.php b/lang/uk/settings.php index afeb2c489..9706b328b 100644 --- a/lang/uk/settings.php +++ b/lang/uk/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Все', 'role_own' => 'Власне', 'role_controlled_by_asset' => 'Контролюється за об\'єктом, до якого вони завантажуються', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Зберегти роль', 'role_users' => 'Користувачі в цій ролі', 'role_users_none' => 'Наразі жоден користувач не призначений для цієї ролі', diff --git a/lang/uz/entities.php b/lang/uz/entities.php index e35a485b6..d3fdb5944 100644 --- a/lang/uz/entities.php +++ b/lang/uz/entities.php @@ -173,6 +173,7 @@ return [ '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_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Kitobni tartiblash: kitob nomi', 'books_sort_name' => 'Nomi bo\'yicha saralash', 'books_sort_created' => 'Yaratilgan sana bo\'yicha saralash', diff --git a/lang/uz/settings.php b/lang/uz/settings.php index 259aee71a..0dbf8351e 100644 --- a/lang/uz/settings.php +++ b/lang/uz/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Hammasi', 'role_own' => 'Shaxsiy', 'role_controlled_by_asset' => 'Ular yuklangan obyekt tomonidan nazorat qilinadi', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Rolni saqlash', 'role_users' => 'Ushbu roldagi foydalanuvchilar', 'role_users_none' => 'Hozirda bu rolga hech qanday foydalanuvchi tayinlanmagan', diff --git a/lang/vi/entities.php b/lang/vi/entities.php index f538f993c..fc1ca6d56 100644 --- a/lang/vi/entities.php +++ b/lang/vi/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => 'Di chuyển các chương và trang trong một cuốn sách để sắp xếp lại nội dung của nó. Các sách khác có thể được thêm vào để dễ dàng di chuyển các chương và trang giữa các sách. Tùy chọn, một quy tắc sắp xếp tự động có thể được đặt để tự động sắp xếp nội dung cuốn sách này khi có thay đổi.', 'books_sort_auto_sort' => 'Tùy chọn sắp xếp tự động', 'books_sort_auto_sort_active' => 'Sắp xếp tự động đang hoạt động: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => 'Sắp xếp sách :bookName', 'books_sort_name' => 'Sắp xếp theo tên', 'books_sort_created' => 'Sắp xếp theo ngày tạo', diff --git a/lang/vi/settings.php b/lang/vi/settings.php index f5f2377c8..c0bca5691 100644 --- a/lang/vi/settings.php +++ b/lang/vi/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => 'Tất cả', 'role_own' => 'Sở hữu', 'role_controlled_by_asset' => 'Kiểm soát các tài sản (asset) người dùng tải lên', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Lưu Quyền', 'role_users' => 'Người dùng được gán quyền này', 'role_users_none' => 'Không có người dùng nào hiện được gán quyền này', diff --git a/lang/zh_CN/entities.php b/lang/zh_CN/entities.php index c4ec0414d..9d57eeb57 100644 --- a/lang/zh_CN/entities.php +++ b/lang/zh_CN/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => '在书籍内部移动章节与页面以重组内容;支持添加其他书籍,实现跨书籍便捷移动章节与页面;还可设置自动排序规则,在内容发生变更时自动对本书内容进行排序。', 'books_sort_auto_sort' => '自动排序选项', 'books_sort_auto_sort_active' => '自动排序已激活:::sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => '排序书籍「:bookName」', 'books_sort_name' => '按名称排序', 'books_sort_created' => '创建时间排序', diff --git a/lang/zh_CN/settings.php b/lang/zh_CN/settings.php index e53e67aba..86dd680b0 100644 --- a/lang/zh_CN/settings.php +++ b/lang/zh_CN/settings.php @@ -207,6 +207,7 @@ return [ 'role_all' => '全部的', 'role_own' => '拥有的', 'role_controlled_by_asset' => '由其所在的资源来控制', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => '保存角色', 'role_users' => '此角色的用户', 'role_users_none' => '目前没有用户被分配到这个角色', diff --git a/lang/zh_TW/entities.php b/lang/zh_TW/entities.php index 46bbe62f9..08886013c 100644 --- a/lang/zh_TW/entities.php +++ b/lang/zh_TW/entities.php @@ -173,6 +173,7 @@ return [ 'books_sort_desc' => '在書籍中移動章節和頁面,重新安排其內容。可加入其他書籍,方便在書籍之間移動章節與頁面。可選擇設定自動排序規則,以便在變更時自動排序此書籍的內容。', 'books_sort_auto_sort' => '自動排序選項', 'books_sort_auto_sort_active' => '自動排序啟動::sortName', + 'books_sort_auto_sort_creation_hint' => 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.', 'books_sort_named' => '排序書本 :bookName', 'books_sort_name' => '按名稱排序', 'books_sort_created' => '按建立時間排序', diff --git a/lang/zh_TW/settings.php b/lang/zh_TW/settings.php index 65778f77c..fd5b088f5 100644 --- a/lang/zh_TW/settings.php +++ b/lang/zh_TW/settings.php @@ -208,6 +208,7 @@ return [ 'role_all' => '全部', 'role_own' => '擁有', 'role_controlled_by_asset' => '依據隸屬的資源來決定', + 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => '儲存角色', 'role_users' => '屬於此角色的使用者', 'role_users_none' => '目前沒有使用者被分配到此角色', From 1532a99d4e0cf7e17c17d8e0f4b26c2e6a08a702 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 5 May 2026 20:35:45 +0100 Subject: [PATCH 47/69] Meta: Updated issue template labels, fixed minor issues --- .forgejo/ISSUE_TEMPLATE/api_request.yml | 2 +- .forgejo/ISSUE_TEMPLATE/bug_report.yml | 2 +- .forgejo/ISSUE_TEMPLATE/feature_request.yml | 2 +- .forgejo/ISSUE_TEMPLATE/language_request.yml | 2 +- .forgejo/ISSUE_TEMPLATE/support_request.yml | 2 +- readme.md | 2 +- tests/Api/TagsApiTest.php | 3 +-- 7 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.forgejo/ISSUE_TEMPLATE/api_request.yml b/.forgejo/ISSUE_TEMPLATE/api_request.yml index def952c52..c68d26258 100644 --- a/.forgejo/ISSUE_TEMPLATE/api_request.yml +++ b/.forgejo/ISSUE_TEMPLATE/api_request.yml @@ -1,6 +1,6 @@ name: New API Endpoint or API Ability description: Request a new endpoint or API feature be added -labels: [":nut_and_bolt: API Request"] +labels: ["Type/API Request"] body: - type: textarea id: feature diff --git a/.forgejo/ISSUE_TEMPLATE/bug_report.yml b/.forgejo/ISSUE_TEMPLATE/bug_report.yml index d301826c9..9e4173cc7 100644 --- a/.forgejo/ISSUE_TEMPLATE/bug_report.yml +++ b/.forgejo/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug Report description: Create a report to help us fix bugs & issues in existing supported functionality -labels: [":bug: Bug"] +labels: ["Type/Bug Report"] body: - type: markdown attributes: diff --git a/.forgejo/ISSUE_TEMPLATE/feature_request.yml b/.forgejo/ISSUE_TEMPLATE/feature_request.yml index c1420cb19..0d799d0a7 100644 --- a/.forgejo/ISSUE_TEMPLATE/feature_request.yml +++ b/.forgejo/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: Feature Request description: Request a new feature or idea to be added to BookStack -labels: [":hammer: Feature Request"] +labels: ["Type/Feature Request"] body: - type: textarea id: description diff --git a/.forgejo/ISSUE_TEMPLATE/language_request.yml b/.forgejo/ISSUE_TEMPLATE/language_request.yml index fad9ef1e8..b86fb08e8 100644 --- a/.forgejo/ISSUE_TEMPLATE/language_request.yml +++ b/.forgejo/ISSUE_TEMPLATE/language_request.yml @@ -1,6 +1,6 @@ name: Language Request description: Request a new language to be added to Crowdin for you to translate -labels: [":earth_africa: Translations"] +labels: ["Focus: Translations"] assignees: - ssddanbrown body: diff --git a/.forgejo/ISSUE_TEMPLATE/support_request.yml b/.forgejo/ISSUE_TEMPLATE/support_request.yml index d60eab711..fde4aad71 100644 --- a/.forgejo/ISSUE_TEMPLATE/support_request.yml +++ b/.forgejo/ISSUE_TEMPLATE/support_request.yml @@ -1,6 +1,6 @@ name: Support Request description: Request support for a specific problem you have not been able to solve yourself -labels: [":dog2: Support"] +labels: ["Type/Support"] body: - type: checkboxes id: useddocs diff --git a/readme.md b/readme.md index 30d4d1789..e9e5d197b 100644 --- a/readme.md +++ b/readme.md @@ -29,7 +29,7 @@ A platform for storing and organising information and documentation. Details for 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. +BookStack is not designed as an extensible platform to be used for purposes that differ from the statement above. 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. diff --git a/tests/Api/TagsApiTest.php b/tests/Api/TagsApiTest.php index a079fa639..e39d72ce1 100644 --- a/tests/Api/TagsApiTest.php +++ b/tests/Api/TagsApiTest.php @@ -1,12 +1,11 @@ Date: Tue, 5 May 2026 20:44:04 +0100 Subject: [PATCH 48/69] Languages: Enabled Thai as a language option --- app/Translation/LocaleManager.php | 1 + lang/en/settings.php | 1 + 2 files changed, 2 insertions(+) diff --git a/app/Translation/LocaleManager.php b/app/Translation/LocaleManager.php index d23c23610..ea32976a8 100644 --- a/app/Translation/LocaleManager.php +++ b/app/Translation/LocaleManager.php @@ -64,6 +64,7 @@ class LocaleManager 'sq' => 'sq_AL', 'sr' => 'sr_RS', 'sv' => 'sv_SE', + 'th' => 'th_TH', 'tk' => 'tk_TM', 'tr' => 'tr_TR', 'uk' => 'uk_UA', diff --git a/lang/en/settings.php b/lang/en/settings.php index 3937c650f..3ccf15e72 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -364,6 +364,7 @@ return [ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', From 50d3be4c95fb9ed17699a86cafc5634c22972250 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 8 May 2026 17:03:27 +0200 Subject: [PATCH 49/69] CI: Made actions more efficient (#6124) Updates our CI process to be more efficient by: - Uses setupphp/node image for more direct access to desired PHP versions. - Adds php extension caching via https://github.com/shivammathur/cache-extensions - Reverted to using MySQL in-test-container to reduce syscalls across the container stack which seemed to be slowing things down. - Update JS testing to only use one worker, to avoid exhausting all CPUs. I think it was attempting to use all threads on the host system before, causing the machine to lock up, since only a subset of cores were available to the environment. Reviewed-on: https://codeberg.org/bookstack/bookstack/pulls/6124 --- .forgejo/workflows/test-js.yml | 2 +- .forgejo/workflows/test-php.yml | 45 ++++++++++++++++++++++++--------- package.json | 3 ++- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/.forgejo/workflows/test-js.yml b/.forgejo/workflows/test-js.yml index 180e6d545..d3e8467fe 100644 --- a/.forgejo/workflows/test-js.yml +++ b/.forgejo/workflows/test-js.yml @@ -29,4 +29,4 @@ jobs: run: npm run ts:lint - name: Run JavaScript tests - run: npm run test \ No newline at end of file + run: npm run test:ci \ No newline at end of file diff --git a/.forgejo/workflows/test-php.yml b/.forgejo/workflows/test-php.yml index 5ff2d14a5..06a6de276 100644 --- a/.forgejo/workflows/test-php.yml +++ b/.forgejo/workflows/test-php.yml @@ -16,26 +16,36 @@ jobs: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: docker container: - image: docker.io/library/node:24-trixie + image: docker.io/setupphp/node:noble strategy: matrix: php: ['8.2', '8.3', '8.4', '8.5'] - services: - mysql: - image: docker.io/library/mariadb:12.2.2-noble - env: - MARIADB_USER: bookstack-test - MARIADB_PASSWORD: bookstack-test - MARIADB_DATABASE: bookstack-test - MARIADB_ROOT_PASSWORD: password + env: + phpextensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp + phpextensioncachekey: cache-v1 steps: - uses: https://code.forgejo.org/actions/checkout@v6 + - name: Setup cache environment + id: extcache + uses: https://github.com/shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php }} + extensions: ${{ env.phpextensions }} + key: ${{ env.phpextensioncachekey }} + + - name: Cache extensions + uses: https://code.forgejo.org/actions/cache@v5 + with: + path: ${{ steps.extcache.outputs.dir }} + key: ${{ steps.extcache.outputs.key }} + restore-keys: ${{ steps.extcache.outputs.key }} + - name: Setup PHP uses: https://github.com/shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp + extensions: ${{ env.phpextensions }} - name: Get Composer Cache Directory id: composer-cache @@ -54,14 +64,25 @@ jobs: env: COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}' + - name: Start MySQL + run: | + sudo systemctl start mysql + + - name: Create database & user + run: | + mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;' + mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';" + mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';" + mysql -uroot -proot -e 'FLUSH PRIVILEGES;' + - name: Migrate and seed the database env: - TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test' + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@localhost/bookstack-test' run: | php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing - name: Run PHP tests env: - TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test' + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@localhost/bookstack-test' run: php${{ matrix.php }} ./vendor/bin/phpunit diff --git a/package.json b/package.json index 80810fff1..2ffebc36a 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "lint": "eslint \"resources/**/*.js\" \"resources/**/*.mjs\"", "fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"", "ts:lint": "tsc --noEmit", - "test": "jest" + "test": "jest", + "test:ci": "jest --maxWorkers=1" }, "devDependencies": { "@eslint/js": "^10.0.1", From 6917eaf7bd35c125304acdc20bd4c5ceff3931d4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 26 Apr 2026 11:48:05 +0100 Subject: [PATCH 50/69] Lexical: Added support for keyCode-based fallback shortcut use Helps in cases where languages like cyrillic may have the relevant key to use but the actual text/.key value is the cyrillic key value instead of the shorcut key we expect. --- resources/js/wysiwyg/services/shortcuts.ts | 70 ++++++++++++++++++---- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index 00abe0c6d..2702b5486 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -94,30 +94,78 @@ const extendedActionsByKeys: Record = { }; function createKeyDownListener(context: EditorUiContext, useExtended: boolean): (e: KeyboardEvent) => void { - const keySetToUse = useExtended ? extendedActionsByKeys : baseActionsByKeys; + const baseKeySetToUse = useExtended ? extendedActionsByKeys : baseActionsByKeys; + const keySetToUse = extendKeySetWithKeyCodes(baseKeySetToUse); return (event: KeyboardEvent) => { - const combo = keyboardEventToKeyComboString(event); - // console.log(`pressed: ${combo}`); - if (keySetToUse[combo]) { - const handled = keySetToUse[combo](context.editor, context); - if (handled) { - event.stopPropagation(); - event.preventDefault(); + const comboStrings = keyboardEventToKeyComboStrings(event); + // console.log(comboStrings, event, keySetToUse); + for (const combo of comboStrings) { + if (keySetToUse[combo]) { + const handled = keySetToUse[combo](context.editor, context); + if (handled) { + event.stopPropagation(); + event.preventDefault(); + } + break; } } }; } -function keyboardEventToKeyComboString(event: KeyboardEvent): string { +/** + * Takes a shortcut key set and returns a new set with added variations of shortcts where + * they can be sensibly represented as their key code instead of just key, which we can use + * for matching in scenarios where the physical key may be represented of the letter used + * in the shortcut, but produces a different 'key' value. + * Useful for Cyrillic scenarios where the keyboard key would show a latin character + * as an option, and therefore be expected for use for the relevant latin shortcut, but the main + * key output is a Cyrillic character. + */ +function extendKeySetWithKeyCodes(keySet: Record): Record { + const newKeys: Record = {}; + + const setKeys = Object.keys(keySet); + for (const keyCombo of setKeys) { + const action = keySet[keyCombo]; + newKeys[keyCombo] = action; + + const comboParts = keyCombo.split('+'); + const lastComboPart = comboParts.pop() || ''; + if (lastComboPart.match(/^[a-zA-Z]$/)) { + const keyCode = lastComboPart.toUpperCase().charCodeAt(0); + comboParts.push(String(keyCode)); + const newCombo = comboParts.join('+'); + newKeys[newCombo] = action; + } + } + + return newKeys; +} + +function keyboardEventToKeyComboStrings(event: KeyboardEvent): string[] { const metaKeyPressed = isMac() ? event.metaKey : event.ctrlKey; - const parts = [ + const mainParts = [ metaKeyPressed ? 'meta' : '', event.shiftKey ? 'shift' : '', event.key, ]; - return parts.filter(Boolean).join('+').toLowerCase(); + const toReturn = [ + mainParts.filter(Boolean).join('+').toLowerCase(), + ]; + + // If ending with a standard latin character, provide an alternative + // keyCode based option for scenarios of dual-language keyboard use. + const keyCode = event.keyCode || 0; + if (keyCode >= 65 && keyCode <= 90) { + const keyCodeParts = [...mainParts]; + keyCodeParts.pop(); + keyCodeParts.push(String(keyCode)); + toReturn.push(keyCodeParts.filter(Boolean).join('+').toLowerCase()); + } + + return toReturn; } function isMac(): boolean { From f1452ebe2a70829a16a85cb50c15ec3d9c9b959e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 4 May 2026 20:52:29 +0100 Subject: [PATCH 51/69] Lexical: Improved content insert on drop handling - Adds specific support for inline content handling. - Adds attempting to use caret position at drop location for accurate placement. --- .../wysiwyg/services/drop-paste-handling.ts | 34 +++++++--------- resources/js/wysiwyg/utils/nodes.ts | 22 +++++++++++ resources/js/wysiwyg/utils/selection.ts | 39 +++++++++++++++++++ 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts index 57f9a80ae..b63c57ada 100644 --- a/resources/js/wysiwyg/services/drop-paste-handling.ts +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -1,40 +1,35 @@ import { $createParagraphNode, $insertNodes, - $isDecoratorNode, COMMAND_PRIORITY_HIGH, DROP_COMMAND, + $isDecoratorNode, $isTextNode, $setSelection, COMMAND_PRIORITY_HIGH, DROP_COMMAND, LexicalEditor, LexicalNode, PASTE_COMMAND } from "lexical"; -import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection"; -import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes"; +import {$insertNewNodesAtSelection, $selectSingleNode} from "../utils/selection"; +import {$getNodePositionFromMouseEvent, $htmlToBlockNodes} from "../utils/nodes"; import {Clipboard} from "../../services/clipboard"; import {$createImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$createLinkNode} from "@lexical/link"; import {EditorImageData, uploadImageFile} from "../utils/images"; import {EditorUiContext} from "../ui/framework/core"; -function $getNodeFromMouseEvent(event: MouseEvent, editor: LexicalEditor): LexicalNode|null { - const x = event.clientX; - const y = event.clientY; - const dom = document.elementFromPoint(x, y); - if (!dom) { - return null; - } - - return $getNearestBlockNodeForCoords(editor, event.clientX, event.clientY); -} function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) { - const positionNode = $getNodeFromMouseEvent(event, editor); + const position = $getNodePositionFromMouseEvent(event, editor); - if (positionNode) { - $selectSingleNode(positionNode); + if (position && $isTextNode(position.node)) { + const selection = position.node.select(position.offset, position.offset); + $setSelection(selection); + } else if (position) { + $selectSingleNode(position.node); } - $insertNewBlockNodesAtSelection(nodes, true); + $insertNewNodesAtSelection(nodes); - if (!$isDecoratorNode(positionNode) || !positionNode?.getTextContent()) { - positionNode?.remove(); + if (position) { + if (!$isDecoratorNode(position.node) && !position.node?.getTextContent()) { + position.node.remove(); + } } } @@ -113,6 +108,7 @@ function handleImageLinkInsert(data: DataTransfer, context: EditorUiContext): bo function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean { const editor = context.editor; return (event: DragEvent): boolean => { + // Template handling const templateId = event.dataTransfer?.getData('bookstack/template') || ''; if (templateId) { diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index ed70bf699..eab8203a0 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -1,5 +1,6 @@ import { $createParagraphNode, + $getNearestNodeFromDOMNode, $getRoot, $isDecoratorNode, $isElementNode, $isRootNode, @@ -64,6 +65,27 @@ export function $getAllNodesOfType(matcher: LexicalNodeMatcher, root?: ElementNo return matches; } +/** + * Get the node based on the given mouse event. + */ +export function $getNodePositionFromMouseEvent(event: MouseEvent, editor: LexicalEditor): {node: LexicalNode, offset: number}|null { + const x = event.clientX; + const y = event.clientY; + const caretPosition = window.document.caretPositionFromPoint(event.x, event.y); + if (!caretPosition) { + const backup = $getNearestBlockNodeForCoords(editor, x, y); + return backup ? {node: backup, offset: 0} : null; + } + + const node = $getNearestNodeFromDOMNode(caretPosition.offsetNode); + if (!node) { + const backup = $getNearestBlockNodeForCoords(editor, x, y); + return backup ? {node: backup, offset: 0} : null; + } + + return {node, offset: caretPosition.offset}; +} + /** * Get the nearest root/block level node for the given position. */ diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index e4b5bf2dc..f5d3699fc 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -109,6 +109,45 @@ export function $insertNewBlockNodesAtSelection(nodes: LexicalNode[], insertAfte } } +export function $insertNewNodesAtSelection(nodes: LexicalNode[]) { + const selection = $getSelection(); + const selectionPoints = selection?.getStartEndPoints(); + let target: LexicalNode|null = null; + let targetBlock: LexicalNode|null = null; + if (selectionPoints) { + const selectionEnd = selectionPoints[1]; + target = selectionEnd.getNode(); + targetBlock = target ? $getNearestNodeBlockParent(target) : null; + } + + for (const node of nodes) { + const isBlock = $isBlockElementNode(node); + + if (isBlock && !targetBlock) { + // Append to the root if its a block and we can't determine position + $getRoot().append(node); + } else if (isBlock && targetBlock) { + // Insert after the target block if we have a block + targetBlock.insertAfter(node); + } else if (!isBlock && selection) { + // Insert at selection if likely inline + selection.insertNodes(nodes); + } else if (!isBlock && $isElementNode(targetBlock)) { + // Append inside the target block if inline but we don't have + // a selection (typically used by the case below) + targetBlock.append(node); + } else { + // Otherwise (where inline) create a new root level paragraph + // and insert content into that. Update the target block + // for re-use by other inline elements. + const paragraph = $createParagraphNode(); + paragraph.append(node); + $getRoot().append(paragraph); + targetBlock = paragraph; + } + } +} + export function $selectSingleNode(node: LexicalNode) { const nodeSelection = $createNodeSelection(); nodeSelection.add(node.getKey()); From 0eed869735eec0f26bb911cd742ad74ccb63e5e4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 4 May 2026 22:32:19 +0100 Subject: [PATCH 52/69] Lexical: Fixed in-editor content drag and drop Now more reliable and aligned to expectations, instead of loosing information and/or deleting elements, or inserting above/below blocks. --- .../wysiwyg/services/drop-paste-handling.ts | 61 ++++++++++++++++--- resources/js/wysiwyg/utils/selection.ts | 36 ++++------- 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts index b63c57ada..e11d6bd5e 100644 --- a/resources/js/wysiwyg/services/drop-paste-handling.ts +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -1,18 +1,21 @@ import { - $createParagraphNode, + $createParagraphNode, $getSelection, $insertNodes, - $isDecoratorNode, $isTextNode, $setSelection, COMMAND_PRIORITY_HIGH, DROP_COMMAND, + $isDecoratorNode, + $isRangeSelection, $isTextNode, $setSelection, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, LexicalEditor, LexicalNode, PASTE_COMMAND } from "lexical"; -import {$insertNewNodesAtSelection, $selectSingleNode} from "../utils/selection"; -import {$getNodePositionFromMouseEvent, $htmlToBlockNodes} from "../utils/nodes"; +import {$getBlockElementNodesInSelection, $insertNewNodesAtSelection, $selectSingleNode} from "../utils/selection"; +import {$getNodePositionFromMouseEvent, $htmlToBlockNodes, $htmlToNodes} from "../utils/nodes"; import {Clipboard} from "../../services/clipboard"; import {$createImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$createLinkNode} from "@lexical/link"; import {EditorImageData, uploadImageFile} from "../utils/images"; import {EditorUiContext} from "../ui/framework/core"; +import {$getHtmlContent} from "@lexical/clipboard"; +const internalActiveDragTracker: WeakMap = new WeakMap(); function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) { const position = $getNodePositionFromMouseEvent(event, editor); @@ -44,6 +47,26 @@ async function insertTemplateToEditor(editor: LexicalEditor, templateId: string, }); } +function insertHtmlToEditor(editor: LexicalEditor, html: string, isFromInternal: boolean, event: DragEvent) { + editor.update(() => { + if (isFromInternal) { + const selected = $getSelection(); + if ($isRangeSelection(selected)) { + selected.removeText(); + const selectionBlocks = $getBlockElementNodesInSelection(selected); + for (const block of selectionBlocks) { + if (block.isEmpty()) { + block.remove(); + } + } + } + } + + const newNodes = $htmlToNodes(editor, html); + $insertNodesAtEvent(newNodes, event, editor); + }); +} + function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolean { const clipboard = new Clipboard(data); let handled = false; @@ -109,6 +132,9 @@ function createDropListener(context: EditorUiContext): (event: DragEvent) => boo const editor = context.editor; return (event: DragEvent): boolean => { + const hadInternalActiveDrag = internalActiveDragTracker.has(editor); + internalActiveDragTracker.delete(editor); + // Template handling const templateId = event.dataTransfer?.getData('bookstack/template') || ''; if (templateId) { @@ -121,10 +147,7 @@ function createDropListener(context: EditorUiContext): (event: DragEvent) => boo // HTML contents drop const html = event.dataTransfer?.getData('text/html') || ''; if (html) { - editor.update(() => { - const newNodes = $htmlToBlockNodes(editor, html); - $insertNodesAtEvent(newNodes, event, editor); - }); + insertHtmlToEditor(editor, html, hadInternalActiveDrag, event); event.preventDefault(); event.stopPropagation(); return true; @@ -161,17 +184,39 @@ function createPasteListener(context: EditorUiContext): (event: ClipboardEvent) }; } +function createDragStartListener(context: EditorUiContext): (event: DragEvent) => boolean { + return (event: DragEvent) => { + // Track when drag events are started internally from the editor + internalActiveDragTracker.set(context.editor, event); + + // If an internal range selection, serialize the range contents + // fully as output HTML, instead of editor HTML + context.editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.extract(); + const html = $getHtmlContent(context.editor, selection); + event.dataTransfer?.setData('text/html', html); + } + }); + return false; + }; +} + export function registerDropPasteHandling(context: EditorUiContext): () => void { const dropListener = createDropListener(context); const pasteListener = createPasteListener(context); + const dragstartListener = createDragStartListener(context); const unregisterDrop = context.editor.registerCommand(DROP_COMMAND, dropListener, COMMAND_PRIORITY_HIGH); const unregisterPaste = context.editor.registerCommand(PASTE_COMMAND, pasteListener, COMMAND_PRIORITY_HIGH); + const unregisterDragStart = context.editor.registerCommand(DRAGSTART_COMMAND, dragstartListener, COMMAND_PRIORITY_HIGH); context.scrollDOM.addEventListener('drop', dropListener); return () => { unregisterDrop(); unregisterPaste(); + unregisterDragStart(); context.scrollDOM.removeEventListener('drop', dropListener); }; } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index f5d3699fc..28050571e 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -111,38 +111,28 @@ export function $insertNewBlockNodesAtSelection(nodes: LexicalNode[], insertAfte export function $insertNewNodesAtSelection(nodes: LexicalNode[]) { const selection = $getSelection(); - const selectionPoints = selection?.getStartEndPoints(); - let target: LexicalNode|null = null; - let targetBlock: LexicalNode|null = null; - if (selectionPoints) { - const selectionEnd = selectionPoints[1]; - target = selectionEnd.getNode(); - targetBlock = target ? $getNearestNodeBlockParent(target) : null; + if (selection) { + selection.insertNodes(nodes); + return; } + // Do something relatively sensible if we don't have a selection within view + const root = $getRoot(); + let targetBlock = root.getLastChild(); for (const node of nodes) { const isBlock = $isBlockElementNode(node); - if (isBlock && !targetBlock) { - // Append to the root if its a block and we can't determine position - $getRoot().append(node); - } else if (isBlock && targetBlock) { - // Insert after the target block if we have a block - targetBlock.insertAfter(node); - } else if (!isBlock && selection) { - // Insert at selection if likely inline - selection.insertNodes(nodes); - } else if (!isBlock && $isElementNode(targetBlock)) { - // Append inside the target block if inline but we don't have - // a selection (typically used by the case below) + root.append(node); + targetBlock = node; + } else if (isBlock) { + targetBlock?.insertAfter(node); + targetBlock = node; + } else if ($isElementNode(targetBlock)) { targetBlock.append(node); } else { - // Otherwise (where inline) create a new root level paragraph - // and insert content into that. Update the target block - // for re-use by other inline elements. const paragraph = $createParagraphNode(); paragraph.append(node); - $getRoot().append(paragraph); + root.append(paragraph); targetBlock = paragraph; } } From df831a0564bee28294e093f8a9e509c1528e8ddf Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 5 May 2026 16:37:24 +0100 Subject: [PATCH 53/69] Lexical: Added RTL support for UI dropdown menus They now show in the correct direction and do not overlap. Added new helper for RTL bounding box handling. --- .../ui/framework/blocks/table-creator.ts | 2 +- .../wysiwyg/ui/framework/helpers/dropdowns.ts | 44 ++++++++++------- resources/js/wysiwyg/ui/framework/manager.ts | 1 + resources/js/wysiwyg/utils/rtl.ts | 49 +++++++++++++++++++ 4 files changed, 77 insertions(+), 19 deletions(-) create mode 100644 resources/js/wysiwyg/utils/rtl.ts diff --git a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts index 6f026ca18..03e55ce64 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts @@ -27,7 +27,7 @@ export class EditorTableCreator extends EditorUiElement { }, rowCells)); } - const display = el('div', {class: 'editor-table-creator-display'}, ['0 x 0']); + const display = el('div', {class: 'editor-table-creator-display', dir: 'ltr'}, ['0 x 0']); const grid = el('div', {class: 'editor-table-creator-grid'}, rows); grid.addEventListener('mousemove', event => { const cell = (event.target as HTMLElement).closest('.editor-table-creator-cell') as HTMLElement|null; diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts index 890d5b325..f86482fdc 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -1,3 +1,5 @@ +import {getViewportRect, RTLRect} from "../../../utils/rtl"; + interface HandleDropdownParams { toggle: HTMLElement; menu: HTMLElement; @@ -7,30 +9,31 @@ interface HandleDropdownParams { showAside?: boolean; } -function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean) { - const toggleRect = toggle.getBoundingClientRect(); - const menuBounds = menu.getBoundingClientRect(); +function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean, isRTL: boolean) { + const toggleRect = new RTLRect(toggle, isRTL); + const menuBounds = new RTLRect(menu, isRTL); + const viewport = getViewportRect(); menu.style.position = 'fixed'; if (showAside) { - let targetLeft = toggleRect.right; - const isRightOOB = toggleRect.right + menuBounds.width > window.innerWidth; - if (isRightOOB) { - targetLeft = Math.max(toggleRect.left - menuBounds.width, 0); + let targetLeft = toggleRect.inlineEnd; + const isEndOOB = toggleRect.inlineEnd + menuBounds.width > viewport.width; + if (isEndOOB) { + targetLeft = Math.max(toggleRect.inlineStart - menuBounds.width, 0); } - menu.style.top = toggleRect.top + 'px'; - menu.style.left = targetLeft + 'px'; + menu.style.top = toggleRect.blockStart + 'px'; + menu.style.insetInlineStart = targetLeft + 'px'; } else { - const isRightOOB = toggleRect.left + menuBounds.width > window.innerWidth; - let targetLeft = toggleRect.left; - if (isRightOOB) { - targetLeft = Math.max(toggleRect.right - menuBounds.width, 0); + const isEndOOB = toggleRect.inlineStart + menuBounds.width > viewport.width; + let targetLeft = toggleRect.inlineStart; + if (isEndOOB) { + targetLeft = Math.max(toggleRect.inlineEnd - menuBounds.width, 0); } - menu.style.top = toggleRect.bottom + 'px'; - menu.style.left = targetLeft + 'px'; + menu.style.top = toggleRect.blockEnd + 'px'; + menu.style.insetInlineStart = targetLeft + 'px'; } } @@ -38,6 +41,7 @@ export class DropDownManager { protected dropdownOptions: WeakMap = new WeakMap(); protected openDropdowns: Set = new Set(); + protected isRTL: boolean = false; constructor() { this.onMenuMouseOver = this.onMenuMouseOver.bind(this); @@ -46,6 +50,10 @@ export class DropDownManager { window.addEventListener('click', this.onWindowClick); } + setIsRTL(isRTL: boolean): void { + this.isRTL = isRTL; + } + teardown(): void { window.removeEventListener('click', this.onWindowClick); } @@ -80,7 +88,7 @@ export class DropDownManager { protected closeDropdown(menu: HTMLElement): void { menu.hidden = true; menu.style.removeProperty('position'); - menu.style.removeProperty('left'); + menu.style.removeProperty('inset-inline-start'); menu.style.removeProperty('top'); this.openDropdowns.delete(menu); @@ -94,8 +102,8 @@ export class DropDownManager { protected openDropdown(menu: HTMLElement): void { const {toggle, showAside, onOpen} = this.getOptions(menu); - menu.hidden = false - positionMenu(menu, toggle, Boolean(showAside)); + menu.hidden = false; + positionMenu(menu, toggle, Boolean(showAside), this.isRTL); this.openDropdowns.add(menu); menu.addEventListener('mouseover', this.onMenuMouseOver); diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 3b4d5b495..f8525bc7c 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -30,6 +30,7 @@ export class EditorUIManager { this.context = context; this.setupEventListeners(); this.setupEditor(context.editor, context); + this.dropdowns.setIsRTL(this.context.manager.getDefaultDirection() === 'rtl'); } getContext(): EditorUiContext { diff --git a/resources/js/wysiwyg/utils/rtl.ts b/resources/js/wysiwyg/utils/rtl.ts new file mode 100644 index 000000000..e04554f34 --- /dev/null +++ b/resources/js/wysiwyg/utils/rtl.ts @@ -0,0 +1,49 @@ +/** + * Create a viewport relative rect for an element which provides + * logical property support. + */ +export class RTLRect { + protected rect: DOMRect; + protected isRTL: boolean; + + constructor(element: HTMLElement, isRTL = false) { + this.rect = element.getBoundingClientRect(); + this.isRTL = isRTL; + } + + get blockStart(): number { + return this.rect.top; + } + + get inlineStart(): number { + if (!this.isRTL) { + return this.rect.left; + } + + return window.innerWidth - this.rect.right; + } + + get blockEnd(): number { + return this.rect.bottom; + } + + get inlineEnd(): number { + if (!this.isRTL) { + return this.rect.right; + } + + return window.innerWidth - this.rect.left; + } + + get width(): number { + return this.rect.width; + } + + get height(): number { + return this.rect.height; + } +} + +export function getViewportRect(): RTLRect { + return new RTLRect(document.documentElement, false); +} From b794f749ddbfb9d84ed53602f18b92c63d3e6e6f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 5 May 2026 20:03:53 +0100 Subject: [PATCH 54/69] Lexical: Updated core inline formats to instead custom built handler Aligns logic used for shortcut handling, so enables these to work for cyrillic equivilent keyboard keys. --- .../js/wysiwyg/lexical/core/LexicalEvents.ts | 13 -------- .../js/wysiwyg/lexical/core/LexicalUtils.ts | 33 ------------------- resources/js/wysiwyg/services/shortcuts.ts | 20 ++++++++--- 3 files changed, 15 insertions(+), 51 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts index 2d197ccc2..7d87d9055 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts @@ -99,7 +99,6 @@ import { getNearestEditorFromDOMNode, getWindow, isAt, isBackspace, - isBold, isCopy, isCut, isDelete, @@ -111,7 +110,6 @@ import { isDeleteWordForward, isEscape, isFirefoxClipboardEvents, - isItalic, isLexicalEditor, isLineBreak, isModifier, @@ -128,7 +126,6 @@ import { isSelectionWithinEditor, isSpace, isTab, - isUnderline, isUndo, } from './LexicalUtils'; @@ -479,7 +476,6 @@ function onClick(event: PointerEvent, editor: LexicalEditor): void { } function onPointerDown(event: PointerEvent, editor: LexicalEditor) { - // TODO implement text drag & drop const target = event.target; const pointerType = event.pointerType; if (target instanceof Node && pointerType !== 'touch') { @@ -1064,15 +1060,6 @@ function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void { dispatchCommand(editor, DELETE_LINE_COMMAND, false); } else if (isAt(key)) { dispatchCommand(editor, KEY_AT_COMMAND, event); - } else if (isBold(key, altKey, metaKey, ctrlKey)) { - event.preventDefault(); - dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold'); - } else if (isUnderline(key, altKey, metaKey, ctrlKey)) { - event.preventDefault(); - dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline'); - } else if (isItalic(key, altKey, metaKey, ctrlKey)) { - event.preventDefault(); - dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic'); } else if (isTab(key, altKey, ctrlKey, metaKey)) { dispatchCommand(editor, KEY_TAB_COMMAND, event); } else if (isUndo(key, shiftKey, metaKey, ctrlKey)) { diff --git a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts index b0bf2f180..cff86a74a 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts @@ -783,39 +783,6 @@ export function isTab( return key === 'Tab' && !altKey && !ctrlKey && !metaKey; } -export function isBold( - key: string, - altKey: boolean, - metaKey: boolean, - ctrlKey: boolean, -): boolean { - return ( - key.toLowerCase() === 'b' && !altKey && controlOrMeta(metaKey, ctrlKey) - ); -} - -export function isItalic( - key: string, - altKey: boolean, - metaKey: boolean, - ctrlKey: boolean, -): boolean { - return ( - key.toLowerCase() === 'i' && !altKey && controlOrMeta(metaKey, ctrlKey) - ); -} - -export function isUnderline( - key: string, - altKey: boolean, - metaKey: boolean, - ctrlKey: boolean, -): boolean { - return ( - key.toLowerCase() === 'u' && !altKey && controlOrMeta(metaKey, ctrlKey) - ); -} - export function isParagraph(key: string, shiftKey: boolean): boolean { return isReturn(key) && !shiftKey; } diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index 2702b5486..f66665703 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -1,4 +1,11 @@ -import {$getSelection, COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical"; +import { + $getSelection, + COMMAND_PRIORITY_HIGH, + FORMAT_TEXT_COMMAND, + KEY_ENTER_COMMAND, + LexicalEditor, + TextFormatType +} from "lexical"; import { cycleSelectionCalloutFormats, formatCodeBlock, insertOrUpdateLink, @@ -27,8 +34,8 @@ function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): Shortcu }; } -function toggleInlineCode(editor: LexicalEditor): boolean { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'); +function toggleInlineFormat(editor: LexicalEditor, format: TextFormatType): boolean { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); return true; } @@ -39,8 +46,11 @@ type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boole * We use "meta" as an abstraction for ctrl/cmd depending on platform. */ const baseActionsByKeys: Record = { - 'meta+8': toggleInlineCode, - 'meta+shift+e': toggleInlineCode, + 'meta+8': (e) => toggleInlineFormat(e, 'code'), + 'meta+shift+e': (e) => toggleInlineFormat(e, 'code'), + 'meta+b': (e) => toggleInlineFormat(e, 'bold'), + 'meta+i': (e) => toggleInlineFormat(e, 'italic'), + 'meta+u': (e) => toggleInlineFormat(e, 'underline'), 'meta+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')), 'meta+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')), 'meta+k': (editor, context) => { From d6b114de7489ca4df591a79b312671555b9e4e14 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 6 May 2026 00:25:43 +0100 Subject: [PATCH 55/69] Lexical: Added some test coverage for shortcut handling Updates existing keydown test helper to accept other event options. --- .../lexical/core/__tests__/utils/index.ts | 15 ++- .../services/__tests__/shortcuts.test.ts | 91 +++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 resources/js/wysiwyg/services/__tests__/shortcuts.test.ts diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index ab54bdb31..a30c86fcc 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -837,22 +837,31 @@ function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); } -export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) { +interface TestKeyboardEventOptions { + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + metaKey?: boolean; + keyCode?: number; +} + +export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string, options: TestKeyboardEventOptions = {}) { const nodeDomEl = editor.getElementByKey(node.getKey()); const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key, + ...options, }); nodeDomEl?.dispatchEvent(event); editor.commitUpdates(); } -export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) { +export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string, options: TestKeyboardEventOptions = {}) { editor.getEditorState().read((): void => { const node = $getSelection()?.getNodes()[0] || null; if (node) { - dispatchKeydownEventForNode(node, editor, key); + dispatchKeydownEventForNode(node, editor, key, options); } }); } diff --git a/resources/js/wysiwyg/services/__tests__/shortcuts.test.ts b/resources/js/wysiwyg/services/__tests__/shortcuts.test.ts new file mode 100644 index 000000000..f7e7298e8 --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/shortcuts.test.ts @@ -0,0 +1,91 @@ +import { + createTestContext, destroyFromContext, + dispatchKeydownEventForSelectedNode, expectEditorStateJSONPropToEqual, +} from "lexical/__tests__/utils"; +import { + $createParagraphNode, $createTextNode, + $getRoot, IS_BOLD, LexicalEditor, +} from "lexical"; +import {registerRichText} from "@lexical/rich-text"; +import {EditorUiContext} from "../../ui/framework/core"; +import {registerShortcuts} from "../shortcuts"; + +describe('Keyboard-handling service tests', () => { + + let context!: EditorUiContext; + let editor!: LexicalEditor; + + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + registerRichText(editor); + registerShortcuts(context, true); + }); + + afterEach(() => { + destroyFromContext(context); + }); + + test('Basic block format shortcuts works', () => { + editor.updateAndCommit(() => { + const p = $createParagraphNode(); + p.append($createTextNode('Hello World')) + $getRoot().append(p); + p.select(); + }); + + dispatchKeydownEventForSelectedNode(editor, '1', {ctrlKey: true}); + + expectEditorStateJSONPropToEqual(editor, '0.type', 'heading'); + expectEditorStateJSONPropToEqual(editor, '0.tag', 'h2'); + + dispatchKeydownEventForSelectedNode(editor, '2', {ctrlKey: true}); + + expectEditorStateJSONPropToEqual(editor, '0.type', 'heading'); + expectEditorStateJSONPropToEqual(editor, '0.tag', 'h3'); + + dispatchKeydownEventForSelectedNode(editor, 'd', {ctrlKey: true}); + + expectEditorStateJSONPropToEqual(editor, '0.type', 'paragraph'); + expectEditorStateJSONPropToEqual(editor, '0.0.text', 'Hello World'); + }); + + test('Basic bold format shortcut works', () => { + editor.updateAndCommit(() => { + const p = $createParagraphNode(); + const text = $createTextNode('Hello World'); + p.append(text) + $getRoot().append(p); + text.select(0, 5); + }); + + // Toggle bold for selection + dispatchKeydownEventForSelectedNode(editor, 'b', {ctrlKey: true}); + expectEditorStateJSONPropToEqual(editor, '0.0.format', IS_BOLD); + expectEditorStateJSONPropToEqual(editor, '0.1.format', 0); + + // Untoggle bold for selection + dispatchKeydownEventForSelectedNode(editor, 'b', {ctrlKey: true}); + expectEditorStateJSONPropToEqual(editor, '0.0.format', 0); + }); + + test('Basic bold format shortcut works when using cyrillic equivalent keys', () => { + editor.updateAndCommit(() => { + const p = $createParagraphNode(); + const text = $createTextNode('Hello World'); + p.append(text) + $getRoot().append(p); + text.select(0, 5); + }); + + // Toggle bold for selection + dispatchKeydownEventForSelectedNode(editor, 'и', {ctrlKey: true, keyCode: 66}); + expectEditorStateJSONPropToEqual(editor, '0.0.format', IS_BOLD); + expectEditorStateJSONPropToEqual(editor, '0.1.format', 0); + + // Untoggle bold for selection + dispatchKeydownEventForSelectedNode(editor, 'и', {ctrlKey: true, keyCode: 66}); + expectEditorStateJSONPropToEqual(editor, '0.0.format', 0); + }); + +}); From dc8f80365cf6e540faccda26079e64c31fc76c6f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 9 May 2026 12:25:49 +0100 Subject: [PATCH 56/69] Lexical: Added missing table header row toggle button Also updated DOM converstion, and CSS, to make lexical format (th inside tbody) somewhat compatible/convertible with the tinymce format (td inside thead). --- resources/icons/editor/table-header.svg | 1 + .../lexical/table/LexicalTableCellNode.ts | 9 ++++--- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 27 +++++++++++++++++-- resources/js/wysiwyg/ui/defaults/toolbars.ts | 15 ++++++++++- resources/js/wysiwyg/utils/tables.ts | 20 +++++++++++--- resources/sass/_tables.scss | 13 ++++++--- 6 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 resources/icons/editor/table-header.svg diff --git a/resources/icons/editor/table-header.svg b/resources/icons/editor/table-header.svg new file mode 100644 index 000000000..e3cfcbb63 --- /dev/null +++ b/resources/icons/editor/table-header.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts index 1c9d7ecf6..0e714e70f 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts @@ -334,10 +334,13 @@ export function $convertTableCellNodeElement( width = parseFloat(domNode_.style.width); } + let isHeader = nodeName === 'th'; + if (domNode instanceof HTMLElement && domNode.closest('thead')) { + isHeader = true; + } + const tableCellNode = $createTableCellNode( - nodeName === 'th' - ? TableCellHeaderStates.ROW - : TableCellHeaderStates.NO_STATUS, + isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS, domNode_.colSpan, width, ); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 2e4883d88..0e01b301c 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -7,6 +7,7 @@ import insertColumnAfterIcon from "@icons/editor/table-insert-column-after.svg"; import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg"; import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; +import tableHeaderIcon from "@icons/editor/table-header.svg"; import {EditorUiContext} from "../../framework/core"; import {$getSelection, BaseSelection} from "lexical"; import { @@ -14,7 +15,7 @@ import { $deleteTableRow__EXPERIMENTAL, $insertTableColumn__EXPERIMENTAL, $insertTableRow__EXPERIMENTAL, $isTableCellNode, - $isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode, + $isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellHeaderStates, TableCellNode, } from "@lexical/table"; import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection"; import {$getParentOfType} from "../../../utils/nodes"; @@ -23,7 +24,7 @@ import { $clearTableFormatting, $clearTableSizes, $getTableFromSelection, $getTableRowsFromSelection, - $mergeTableCellsInSelection + $mergeTableCellsInSelection, $toggleRowCellHeaderState } from "../../../utils/tables"; import { $copySelectedColumnsToClipboard, @@ -239,6 +240,28 @@ export const pasteRowAfter: EditorButtonDefinition = { isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(), }; +export const toggleRowHeaders: EditorButtonDefinition = { + label: 'Row header', + format: 'small', + icon: tableHeaderIcon, + action(context: EditorUiContext, button) { + context.editor.update(() => { + const row = $getNodeFromSelection($getSelection(), $isTableCellNode)?.getParent(); + if (!$isTableRowNode(row)) { + return; + } + + const isNowHeader = $toggleRowCellHeaderState(row); + button.setActiveState(isNowHeader); + }); + }, + isActive: (selection) => { + return $selectionContainsNodeType(selection, (node) => { + return $isTableCellNode(node) && node.getHeaderStyles() !== TableCellHeaderStates.NO_STATUS; + }); + } +}; + export const cutColumn: EditorButtonDefinition = { label: 'Cut column', format: 'long', diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index a3ada5c89..99d3b96d4 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -28,7 +28,7 @@ import { pasteRowBefore, resizeTableToContents, rowProperties, splitCell, - table, tableProperties + table, tableProperties, toggleRowHeaders } from "./buttons/tables"; import {about, fullscreen, redo, source, undo} from "./buttons/controls"; import { @@ -284,6 +284,19 @@ export const contextToolbars: Record = { return originalTarget.closest('table') as HTMLTableElement; } }, + table_header: { + selector: 'table tr:first-of-type td, table tr:first-of-type th', + content() { + return [ + new EditorOverflowContainer('table_headers', 1, [ + new EditorButton(toggleRowHeaders), + ]), + ]; + }, + displayTargetLocator(originalTarget: HTMLElement): HTMLElement { + return originalTarget.closest('table') as HTMLTableElement; + } + }, details: { selector: 'details', content() { diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index 15cc3cbbe..b835614ab 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -3,7 +3,7 @@ import { $isTableCellNode, $isTableNode, $isTableRowNode, - $isTableSelection, TableCellNode, TableNode, + $isTableSelection, TableCellHeaderStates, TableCellNode, TableNode, TableRowNode, TableSelection, } from "@lexical/table"; @@ -328,9 +328,21 @@ export function $getCellPaddingForTable(table: TableNode): string { return padding || ''; } - - - +/** + * Toggle the header state of the cells in the provided row. + * Returns a boolean to indicate if the new state of the cells is as headers. + */ +export function $toggleRowCellHeaderState(row: TableRowNode): boolean { + const firstCell = row.getFirstChild(); + const isHeader = $isTableCellNode(firstCell) ? firstCell.getHeaderStyles() !== TableCellHeaderStates.NO_STATUS : false; + const cells = row.getChildren(); + for (const cell of cells) { + if ($isTableCellNode(cell)) { + cell.setHeaderStyles(isHeader ? TableCellHeaderStates.NO_STATUS : TableCellHeaderStates.ROW); + } + } + return !isHeader; +} diff --git a/resources/sass/_tables.scss b/resources/sass/_tables.scss index 16be32fb3..5ea520d73 100644 --- a/resources/sass/_tables.scss +++ b/resources/sass/_tables.scss @@ -4,10 +4,6 @@ table { min-width: 100px; max-width: 100%; - thead { - @include mixins.lightDark(background-color, #f8f8f8, #333); - font-weight: 500; - } td, th { min-width: 10px; padding: 6px 8px; @@ -22,6 +18,15 @@ table { } } +// Table Header styles. +// Initial selector for in-body th cells, intended to (somewhat) not conflict with +// previous approach (second selector) to table headers where +// we would target the thead specifically. +table:not(:has(thead)) th, table thead { + @include mixins.lightDark(background-color, #f8f8f8, #333); + font-weight: 500; +} + table.table { width: 100%; tr td, tr th { From 7254dc3ab54f6005a5075d4ce10d2d09fb597727 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 9 May 2026 15:38:44 +0100 Subject: [PATCH 57/69] Lexical: Fixed diagrams not updating on edit Caused by lack of proper key use on clone --- resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts | 2 +- resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts index e69f97848..6be19c293 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts @@ -27,7 +27,7 @@ export class DiagramNode extends DecoratorNode { } static clone(node: DiagramNode): DiagramNode { - const newNode = new DiagramNode(node.__drawingId, node.__drawingUrl); + const newNode = new DiagramNode(node.__drawingId, node.__drawingUrl, node.__key); newNode.__id = node.__id; return newNode; } diff --git a/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts b/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts index e46dcc312..58a4de1dc 100644 --- a/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts +++ b/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts @@ -1,5 +1,4 @@ import {EditorDecorator} from "../framework/decorator"; -import {EditorUiContext} from "../framework/core"; import {BaseSelection, CLICK_COMMAND, COMMAND_PRIORITY_NORMAL} from "lexical"; import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; From 1ef9b7d48fb9180dea11d3770e1e1e2ccdc1ab5f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 9 May 2026 16:05:21 +0100 Subject: [PATCH 58/69] Lexical: Added a little testing coverage for DiagramNode --- .../js/wysiwyg/lexical/core/LexicalNode.ts | 6 ++-- .../lexical/core/__tests__/utils/index.ts | 4 +++ .../__tests__/unit/LexicalDiagramNode.test.ts | 33 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDiagramNode.test.ts diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index 95a723469..4aba2a22f 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -208,9 +208,9 @@ export class LexicalNode { } /** - * Clones this node, creating a new node with a different key - * and adding it to the EditorState (but not attaching it anywhere!). All nodes must - * implement this method. + * Clones this node, creating a new matching instance. + * Should be created with the existing node key if it exists. + * All nodes must implement this method. * */ static clone(_data: unknown): LexicalNode { diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index a30c86fcc..c18c4b4ca 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -39,6 +39,8 @@ import {EditorUiContext} from "../../../../ui/framework/core"; import {EditorUIManager} from "../../../../ui/framework/manager"; import {ImageNode} from "@lexical/rich-text/LexicalImageNode"; import {MediaNode} from "@lexical/rich-text/LexicalMediaNode"; +import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; +import {DiagramDecorator} from "../../../../ui/decorators/DiagramDecorator"; type TestEnv = { readonly container: HTMLDivElement; @@ -489,6 +491,7 @@ export function createTestContext(): EditorUiContext { nodes: [ ImageNode, MediaNode, + DiagramNode, ] }); @@ -509,6 +512,7 @@ export function createTestContext(): EditorUiContext { }; context.manager.setContext(context); + context.manager.registerDecoratorType('diagram', DiagramDecorator); return context; } diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDiagramNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDiagramNode.test.ts new file mode 100644 index 000000000..e6bb244f4 --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDiagramNode.test.ts @@ -0,0 +1,33 @@ +import {createTestContext} from "lexical/__tests__/utils"; +import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; +import {$getHtmlContent} from "@lexical/clipboard"; +import {getEditorContentAsHtml} from "../../../../utils/actions"; +import {$getRoot} from "lexical"; + + +describe('LexicalDiagramNode', () => { + + test('clone creates new instance with same key', () => { + const {editor} = createTestContext(); + editor.updateAndCommit(() => { + const node = $createDiagramNode('10', 'https://example.com/barry.png'); + const clone = DiagramNode.clone(node); + + expect(node).not.toBe(clone); + expect(node.getKey()).toBe(clone.getKey()); + }); + }); + + test('output HTML format', async () => { + const {editor} = createTestContext(); + editor.updateAndCommit(() => { + const node = $createDiagramNode('10', 'https://example.com/barry.png'); + node.setId('cat-123'); + $getRoot().append(node); + }); + + const html = await getEditorContentAsHtml(editor); + expect(html).toBe(`
    `); + }); + +}); \ No newline at end of file From b53499932bdcfa29b1a6c20aaf9e52f3e49fc03c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 9 May 2026 17:54:24 +0100 Subject: [PATCH 59/69] Lexical: Fixed actions not applying on empty state Updated editor to always attempt to start from at least a paragraph isntead of empty state. Improved focus on HTML change. --- .../js/wysiwyg/ui/defaults/forms/controls.ts | 6 +- .../wysiwyg/utils/__tests__/actions.test.ts | 107 ++++++++++++++++++ resources/js/wysiwyg/utils/actions.ts | 17 ++- .../pages/parts/wysiwyg-editor.blade.php | 2 +- 4 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 resources/js/wysiwyg/utils/__tests__/actions.test.ts diff --git a/resources/js/wysiwyg/ui/defaults/forms/controls.ts b/resources/js/wysiwyg/ui/defaults/forms/controls.ts index 8e7219d67..c92a16834 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/controls.ts @@ -1,12 +1,16 @@ import {EditorFormDefinition} from "../../framework/forms"; import {EditorUiContext, EditorUiElement} from "../../framework/core"; -import {setEditorContentFromHtml} from "../../../utils/actions"; +import {focusEditor, setEditorContentFromHtml} from "../../../utils/actions"; import {ExternalContent} from "../../framework/blocks/external-content"; export const source: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || ''); + context.editor.commitUpdates(); + window.requestAnimationFrame(() => { + focusEditor(context.editor); + }); return true; }, fields: [ diff --git a/resources/js/wysiwyg/utils/__tests__/actions.test.ts b/resources/js/wysiwyg/utils/__tests__/actions.test.ts new file mode 100644 index 000000000..9450638f5 --- /dev/null +++ b/resources/js/wysiwyg/utils/__tests__/actions.test.ts @@ -0,0 +1,107 @@ +import { + createTestContext, + destroyFromContext, + expectNodeShapeToMatch, +} from "lexical/__tests__/utils"; +import { $createParagraphNode, $createTextNode, $getRoot, LexicalEditor } from "lexical"; +import { EditorUiContext } from "../../ui/framework/core"; +import { setEditorContentFromHtml } from "../actions"; + +describe('Actions', () => { + + let context!: EditorUiContext; + let editor!: LexicalEditor; + + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + }); + + afterEach(() => { + destroyFromContext(context); + }); + + describe('setEditorContentFromHtml', () => { + it('parses HTML and sets content in editor', async () => { + setEditorContentFromHtml(editor, '

    Hello World

    '); + await Promise.resolve().then(); + + expectNodeShapeToMatch(editor, [ + { + type: 'paragraph', + children: [{ text: 'Hello World' }], + }, + ]); + }); + + it('parses multiple block elements', async () => { + setEditorContentFromHtml(editor, '

    First

    Second

    '); + await Promise.resolve().then(); + + expectNodeShapeToMatch(editor, [ + { type: 'paragraph', children: [{ text: 'First' }] }, + { type: 'paragraph', children: [{ text: 'Second' }] }, + ]); + }); + + it('wraps plain text in a paragraph', async () => { + setEditorContentFromHtml(editor, 'Plain text'); + await Promise.resolve().then(); + + expectNodeShapeToMatch(editor, [ + { + type: 'paragraph', + children: [{ text: 'Plain text' }], + }, + ]); + }); + + it('ensures at least a paragraph when HTML is empty', async () => { + setEditorContentFromHtml(editor, ''); + await Promise.resolve().then(); + + expectNodeShapeToMatch(editor, [ + { type: 'paragraph' }, + ]); + }); + + it('ensures at least a paragraph when HTML contains only whitespace', async () => { + setEditorContentFromHtml(editor, ' '); + await Promise.resolve().then(); + + expectNodeShapeToMatch(editor, [ + { type: 'paragraph' }, + ]); + }); + + it('clears existing content before setting new content', async () => { + editor.updateAndCommit(() => { + const p = $createParagraphNode(); + p.append($createTextNode('Existing')); + $getRoot().append(p); + }); + + setEditorContentFromHtml(editor, '

    New

    '); + await Promise.resolve().then(); + + expectNodeShapeToMatch(editor, [ + { type: 'paragraph', children: [{ text: 'New' }] }, + ]); + }); + + it('handles nested HTML structures', async () => { + setEditorContentFromHtml(editor, '
    • Item A
    • Item B
    '); + await Promise.resolve().then(); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [ + { type: 'listitem', children: [{ text: 'Item A' }] }, + { type: 'listitem', children: [{ text: 'Item B' }] }, + ], + }, + ]); + }); + }); +}); diff --git a/resources/js/wysiwyg/utils/actions.ts b/resources/js/wysiwyg/utils/actions.ts index e18ac515f..465d44d31 100644 --- a/resources/js/wysiwyg/utils/actions.ts +++ b/resources/js/wysiwyg/utils/actions.ts @@ -1,4 +1,4 @@ -import {$getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical"; +import {$createParagraphNode, $getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical"; import {$generateHtmlFromNodes} from "@lexical/html"; import {$getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes"; @@ -12,6 +12,12 @@ export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { const nodes = $htmlToBlockNodes(editor, html); root.append(...nodes); + + // Always ensure we at least have a paragraph in the root + // as a target for the cursor/focus/actions. + if (root.isEmpty()) { + root.append($createParagraphNode()); + } }); } @@ -85,5 +91,14 @@ export function getEditorContentAsHtml(editor: LexicalEditor): Promise { } export function focusEditor(editor: LexicalEditor): void { + editor.update(() => { + const root = $getRoot(); + const selection = $getSelection(); + const firstChild = root.getFirstChild(); + if (firstChild && !selection) { + firstChild.selectStart(); + } + }); + editor.commitUpdates(); editor.focus(() => {}, {defaultSelection: "rootStart"}); } \ No newline at end of file diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index 73e655752..4344b234d 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -11,7 +11,7 @@ {{--
    --}} - +
    @if($errors->has('html')) From 5f306801d7a192021a01021aec07a7976514d94e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 9 May 2026 19:17:37 +0100 Subject: [PATCH 60/69] Lexical: Used non-breaking spaces instead of text spans for whitespace Leading/trailing text whitespace would use spans with css whitespace rules so the spaces would be represented, but this would lead to a messier output. Instead, this attempts to smartly use non-breaking spaces, along with normal spaces, to represent white space in a way that still allows breaking. It attempts to reduce non-breaking spaces where not needed (next to other inline content). Also fixes duplicate usage on italic content. --- .../js/wysiwyg/lexical/core/LexicalUtils.ts | 4 +++ .../lexical/core/nodes/LexicalTextNode.ts | 30 ++++++++++++---- .../__tests__/unit/LexicalTextNode.test.ts | 35 ++++++++++++++++--- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts index cff86a74a..840c5e8ab 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts @@ -1338,6 +1338,10 @@ export function $isInlineElementOrDecoratorNode(node: LexicalNode): boolean { ); } +export function $isInlineElementOrTextNode(node: LexicalNode|null): boolean { + return node !== null && ($isTextNode(node) || ($isElementNode(node) && node.isInline())); +} + export function $getNearestRootOrShadowRoot( node: LexicalNode, ): RootNode | ElementNode { diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts index 35cc073a0..6588b7e10 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts @@ -58,7 +58,7 @@ import { import {errorOnReadOnly} from '../LexicalUpdates'; import { $applyNodeReplacement, - $getCompositionKey, + $getCompositionKey, $isInlineElementOrTextNode, $setCompositionKey, getCachedClassNameArray, internalMarkSiblingsAsDirty, @@ -275,6 +275,16 @@ function wrapElementWith( return el; } +function alternatingWhitespaceReplacer(inlineAdjacent: boolean): (match: string) => string { + return (match: string): string => { + let offset = inlineAdjacent ? 1 : 0; + return match + .split('') + .map((char, i) => (i % 2 === offset ? '\u00A0' : char)) + .join(''); + }; +} + // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export interface TextNode { getTopLevelElement(): ElementNode | null; @@ -626,10 +636,15 @@ export class TextNode extends LexicalNode { 'Expected TextNode createDOM to always return a HTMLElement', ); - // Wrap up to retain space if head/tail whitespace exists - const text = this.getTextContent(); - if (/^\s|\s$/.test(text)) { - element.style.whiteSpace = 'pre-wrap'; + // Handle head/tail whitespace if it exists + let text = this.getTextContent(); + const prevIsInline = $isInlineElementOrTextNode(this.getPreviousSibling()); + const nextIsInline = $isInlineElementOrTextNode(this.getNextSibling()); + if (/^\s/.test(text)) { + text = text.replace(/^(\s+)/, alternatingWhitespaceReplacer(prevIsInline)); + } + if (/\s$/.test(text)) { + text = text.replace(/(\s+)$/, alternatingWhitespaceReplacer(nextIsInline)); } // Strip editor theme classes @@ -642,6 +657,9 @@ export class TextNode extends LexicalNode { element.removeAttribute('class'); } + // Apply whitespace replacement to the element + element.textContent = text; + // Remove placeholder tag if redundant if (element.nodeName === 'SPAN' && !element.getAttribute('style')) { element = document.createTextNode(text); @@ -653,7 +671,7 @@ export class TextNode extends LexicalNode { if (this.hasFormat('bold') && originalElementName !== 'strong') { element = wrapElementWith(element, 'strong'); } - if (this.hasFormat('italic')) { + if (this.hasFormat('italic') && originalElementName !== 'em') { element = wrapElementWith(element, 'em'); } if (this.hasFormat('strikethrough')) { diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts index 0dbf9b94e..8c81d13ac 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts @@ -42,6 +42,7 @@ import { getEditorStateTextContent, } from '../../../LexicalUtils'; import {$generateHtmlFromNodes} from "@lexical/html"; +import {setEditorContentFromHtml} from "../../../../../utils/actions"; const editorConfig = Object.freeze({ namespace: '', @@ -806,12 +807,13 @@ describe('LexicalTextNode tests', () => { }); }); - test('simple text wrapped in span if leading or ending spacing', async () => { + test('non-breaking-spaces used if leading or ending spacing', async () => { const textByExpectedHtml = { - 'hello ': '

    hello

    ', - ' hello': '

    hello

    ', - ' hello ': '

    hello

    ', + 'hello ': '

    hello 

    ', + ' hello': '

     hello

    ', + ' hello ': '

     hello 

    ', + 'hello ': '

    hello   

    ', } await update(() => { @@ -827,6 +829,31 @@ describe('LexicalTextNode tests', () => { }); }); + test('normal spaces used when text is adjacent to other inline text', async () => { + await update(() => { + setEditorContentFromHtml($getEditor(), '

     Hello there is text here 

    '); + }); + + await update(() => { + const html = $generateHtmlFromNodes($getEditor(), null); + expect(html).toBe('

     Hello there is text here 

    '); + }); + }); + + test('normal and non-breaking spaces used when text with multiple spaces is adjacent to inline text', async () => { + await update(() => { + const paragraph = $getRoot().getFirstChild()!; + $getRoot().append(paragraph); + paragraph.append($createTextNode('hello ')); + const bold = $createTextNode('world '); + bold.setFormat("bold"); + paragraph.append(bold); + + const html = $generateHtmlFromNodes($getEditor(), null); + expect(html).toBe('

    hello   world   

    '); + }); + }); + test('text with formats exports using format elements instead of classes', async () => { await update(() => { const paragraph = $getRoot().getFirstChild()!; From 16a50b0ca9af301482b5d1ad32a5e16d99ec3e7e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 9 May 2026 19:36:43 +0100 Subject: [PATCH 61/69] Lexical: Fixed updating of TextNode text on export --- .../js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts index 6588b7e10..860f03925 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts @@ -657,12 +657,17 @@ export class TextNode extends LexicalNode { element.removeAttribute('class'); } - // Apply whitespace replacement to the element - element.textContent = text; - // Remove placeholder tag if redundant if (element.nodeName === 'SPAN' && !element.getAttribute('style')) { element = document.createTextNode(text); + } else { + // Apply whitespace replaced text to the element + // Search down the child chain in the event this element is already wrapped + let child: Element = element; + while (child.childElementCount > 0) { + child = child.children[0]; + } + child.textContent = text; } // This is the only way to properly add support for most clients, From e98244398824fa622db86b72ec8e76f2c3461ccc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 10 May 2026 12:58:17 +0100 Subject: [PATCH 62/69] Lexical: Made toolbar placement smarter - Set z-index so toolbars for deeper (more specific) content is bought forward above more general toolbars. - Added some basic overlap checking as an indicator to whether the toolbar should sit above the target. --- resources/js/wysiwyg/ui/framework/manager.ts | 8 +- resources/js/wysiwyg/ui/framework/toolbars.ts | 75 +++++++++++++++---- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index f8525bc7c..4dab455fe 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -167,8 +167,10 @@ export class EditorUIManager { triggerLayoutUpdate(): void { window.requestAnimationFrame(() => { + const toolbarBounds: (DOMRect|null)[] = []; for (const toolbar of this.activeContextToolbars) { - toolbar.updatePosition(); + const bounds = toolbar.updatePosition(toolbarBounds); + toolbarBounds.push(bounds); } }); } @@ -247,13 +249,15 @@ export class EditorUIManager { } } + const toolbarBounds: (DOMRect|null)[] = []; for (const [target, contents] of contentByTarget) { const toolbar = new EditorContextToolbar(target, contents); toolbar.setContext(this.getContext()); this.activeContextToolbars.push(toolbar); this.getContext().containerDOM.append(toolbar.getDOMElement()); - toolbar.updatePosition(); + const bounds = toolbar.updatePosition(toolbarBounds); + toolbarBounds.push(bounds); } } diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts index cf5ec4ad1..bd86eca7e 100644 --- a/resources/js/wysiwyg/ui/framework/toolbars.ts +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -23,8 +23,14 @@ export class EditorContextToolbar extends EditorContainerUiElement { }, this.getChildren().map(child => child.getDOMElement())); } - updatePosition() { - const editorBounds = this.getContext().scrollDOM.getBoundingClientRect(); + /** + * Update the position of the toolbar based on the target element. + * Takes the bounds of other toolbars processed so far so that they can be considered + * when positioning the toolbar to help prevent overlaps. + */ + updatePosition(otherBounds: (DOMRect|null)[] = []): DOMRect|null { + const context = this.getContext(); + const editorBounds = context.scrollDOM.getBoundingClientRect(); const targetBounds = this.target.getBoundingClientRect(); const dom = this.getDOMElement(); const domBounds = dom.getBoundingClientRect(); @@ -36,23 +42,43 @@ export class EditorContextToolbar extends EditorContainerUiElement { if (!this.target.isConnected) { // If our target is no longer in the DOM, tell the manager an update is needed. - this.getContext().manager.triggerFutureStateRefresh(); - return; + context.manager.triggerFutureStateRefresh(); + return null; } else if (!showing) { - return; + return null; } - const showAbove: boolean = targetBounds.bottom + 6 + domBounds.height > editorBounds.bottom; - dom.classList.toggle('is-above', showAbove); - const targetMid = targetBounds.left + (targetBounds.width / 2); - const targetLeft = targetMid - (domBounds.width / 2); + const intendedBounds: DOMRectInit = { + x: targetMid -(domBounds.width / 2), + y: targetBounds.bottom + 6, + width: domBounds.width, + height: domBounds.height, + }; + + let showAbove: boolean = ( + targetBounds.bottom + 6 + domBounds.height > editorBounds.bottom + || this.willOverlapWithOthersIfBelow(intendedBounds, otherBounds) + ); + dom.classList.toggle('is-above', showAbove); if (showAbove) { - dom.style.top = (targetBounds.top - 6 - domBounds.height) + 'px'; - } else { - dom.style.top = (targetBounds.bottom + 6) + 'px'; + intendedBounds.y = targetBounds.top - 6 - domBounds.height; } - dom.style.left = targetLeft + 'px'; + + dom.style.top = intendedBounds.y + 'px'; + dom.style.left = intendedBounds.x + 'px'; + + // Set z-index based on depth, so that the most specific toolbar + // is bought forward. + let depth = 1; + let parent = this.target.parentElement; + while (parent && parent !== context.editorDOM) { + parent = parent.parentElement; + depth++; + } + dom.style.zIndex = `${depth}`; + + return dom.getBoundingClientRect(); } insert(children: EditorUiElement[]) { @@ -60,4 +86,27 @@ export class EditorContextToolbar extends EditorContainerUiElement { const dom = this.getDOMElement(); dom.append(...children.map(child => child.getDOMElement())); } + + protected willOverlapWithOthersIfBelow(intendedBounds: DOMRectInit, otherBounds: (DOMRect|null)[]): boolean { + for (const bounds of otherBounds) { + if (bounds === null) continue; + + const iLeft = intendedBounds.x ?? 0; + const iTop = intendedBounds.y ?? 0; + const iRight = iLeft + (intendedBounds.width ?? 0); + const iBottom = iTop + (intendedBounds.height ?? 0); + + const overlaps = ( + iLeft < bounds.right && + iRight > bounds.left && + iTop < bounds.bottom && + iBottom > bounds.top + ); + + if (overlaps) { + return true; + } + } + return false; + } } \ No newline at end of file From 6367f007c18b7e7bfac61fd077c9b777aa03a7ee Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 10 May 2026 13:04:39 +0100 Subject: [PATCH 63/69] Lexical: Added fade to table resizers --- resources/js/wysiwyg/ui/framework/toolbars.ts | 4 ++-- resources/sass/_editor.scss | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts index bd86eca7e..95f0af11c 100644 --- a/resources/js/wysiwyg/ui/framework/toolbars.ts +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -50,7 +50,7 @@ export class EditorContextToolbar extends EditorContainerUiElement { const targetMid = targetBounds.left + (targetBounds.width / 2); const intendedBounds: DOMRectInit = { - x: targetMid -(domBounds.width / 2), + x: targetMid - (domBounds.width / 2), y: targetBounds.bottom + 6, width: domBounds.width, height: domBounds.height, @@ -109,4 +109,4 @@ export class EditorContextToolbar extends EditorContainerUiElement { } return false; } -} \ No newline at end of file +} diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 0580d7377..4c05de0a8 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -461,6 +461,7 @@ body.editor-is-fullscreen { z-index: 3; user-select: none; opacity: 0; + transition: opacity 120ms ease-in-out; &:hover, &.active { opacity: 0.4; } From 2aba39b176a9350750c0cfced812f479339961bd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 10 May 2026 17:28:21 +0100 Subject: [PATCH 64/69] Lexical: Updated toolbars to re-focus on editor on escape press --- resources/js/wysiwyg/ui/framework/core.ts | 2 +- resources/js/wysiwyg/ui/framework/toolbars.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 9c524dff0..68a80d2e1 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -30,7 +30,7 @@ export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDef export abstract class EditorUiElement { protected dom: HTMLElement|null = null; private context: EditorUiContext|null = null; - private abortController: AbortController = new AbortController(); + protected abortController: AbortController = new AbortController(); protected abstract buildDOM(): HTMLElement; diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts index 95f0af11c..ec8e9772b 100644 --- a/resources/js/wysiwyg/ui/framework/toolbars.ts +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -18,9 +18,20 @@ export class EditorContextToolbar extends EditorContainerUiElement { } protected buildDOM(): HTMLElement { - return el('div', { + const toolbar = el('div', { class: 'editor-context-toolbar', }, this.getChildren().map(child => child.getDOMElement())); + + // Focus back on the editor on escape press + toolbar.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.getContext().editor.focus(); + } + }, {signal: this.abortController.signal}); + + return toolbar; } /** From 5d429ea9bb5d48b4a4b3bab4b09fe21dea00b752 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 11 May 2026 17:07:50 +0100 Subject: [PATCH 65/69] Lexical: Added better support for block content in list items Improved the ability to set/use block formats in lists. Setting a format, will now attempt to create that, if just plain text to start with, when in a list. Added handling so that blocks split into new list elements instead of new blocks within the list element. Also fixed some issues with cyclic import references, and updated editor event types to always include their type for easier debug. --- resources/js/wysiwyg/index.ts | 3 ++ .../wysiwyg/lexical/core/LexicalCommands.ts | 2 +- .../wysiwyg/lexical/core/LexicalSelection.ts | 7 ++- .../lexical/core/nodes/LexicalElementNode.ts | 4 +- .../lexical/list/LexicalListItemNode.ts | 4 +- .../wysiwyg/lexical/list/LexicalListNode.ts | 2 +- .../js/wysiwyg/lexical/list/formatList.ts | 8 ++-- resources/js/wysiwyg/lexical/list/index.ts | 38 +++++++++++++++- resources/js/wysiwyg/lexical/list/utils.ts | 6 ++- resources/js/wysiwyg/utils/formats.ts | 45 +++++++++++-------- resources/js/wysiwyg/utils/selection.ts | 25 +++++++++++ 11 files changed, 112 insertions(+), 32 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index dc0ea211f..59fb2acde 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -29,6 +29,7 @@ import {registerSelectionHandling} from "./services/selection-handling"; import {EditorApi} from "./api/api"; import {registerMentions} from "./services/mentions"; import {MentionDecorator} from "./ui/decorators/MentionDecorator"; +import {registerLists} from "@lexical/list"; const theme = { text: { @@ -58,6 +59,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st mergeRegister( registerRichText(editor), + registerLists(editor), registerHistory(editor, createEmptyHistoryState(), 300), registerShortcuts(context, true), registerKeyboardHandling(context), @@ -122,6 +124,7 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s const editorTeardown = mergeRegister( registerRichText(editor), + registerLists(editor), registerHistory(editor, createEmptyHistoryState(), 300), registerShortcuts(context, false), registerAutoLinks(editor), diff --git a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts index 1b378b4a0..36badba97 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts @@ -16,7 +16,7 @@ import type { export type PasteCommandType = ClipboardEvent | InputEvent | KeyboardEvent; export function createCommand(type?: string): LexicalCommand { - return __DEV__ ? {type} : {}; + return {type}; } export const SELECTION_CHANGE_COMMAND: LexicalCommand = createCommand( diff --git a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts index 36e2db547..2904867f0 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts @@ -63,7 +63,6 @@ import { toggleTextFormatType, } from './LexicalUtils'; import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode'; -import {$selectSingleNode} from "../../utils/selection"; export type TextPointType = { _selection: BaseSelection; @@ -2224,6 +2223,12 @@ export function $createNodeSelection(): NodeSelection { return new NodeSelection(new Set()); } +function $selectSingleNode(node: LexicalNode): void { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(node.getKey()); + $setSelection(nodeSelection); +} + export function $internalCreateSelection( editor: LexicalEditor, ): null | BaseSelection { diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts index 0329aed6f..0304fe4ad 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -16,11 +16,9 @@ import type {KlassConstructor, Spread} from 'lexical'; import invariant from 'lexical/shared/invariant'; -import {$isTextNode, TextNode} from '../index'; +import {$isTextNode, TextNode} from './LexicalTextNode'; import { DOUBLE_LINE_BREAK, - - } from '../LexicalConstants'; import {LexicalNode} from '../LexicalNode'; import { diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index 239c49a8c..a7b04d476 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -6,7 +6,7 @@ * */ -import type {ListNode, ListType} from './'; +import type {ListNode, ListType} from './LexicalListNode'; import type { BaseSelection, DOMConversionMap, @@ -32,7 +32,7 @@ import { } from 'lexical'; import invariant from 'lexical/shared/invariant'; -import {$createListNode, $isListNode} from './'; +import {$createListNode, $isListNode} from './LexicalListNode'; import {mergeLists} from './formatList'; import {isNestedListNode} from './utils'; import {el} from "../../utils/dom"; diff --git a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts index b5c83addd..0311e8b31 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts @@ -30,7 +30,7 @@ import { import invariant from 'lexical/shared/invariant'; import normalizeClassNames from 'lexical/shared/normalizeClassNames'; -import {$createListItemNode, $isListItemNode, ListItemNode} from '.'; +import {$createListItemNode, $isListItemNode, ListItemNode} from './LexicalListItemNode'; import { mergeNextSiblingListIfSameType, updateChildrenListItemValue, diff --git a/resources/js/wysiwyg/lexical/list/formatList.ts b/resources/js/wysiwyg/lexical/list/formatList.ts index aa0d5d611..5c7b81b7a 100644 --- a/resources/js/wysiwyg/lexical/list/formatList.ts +++ b/resources/js/wysiwyg/lexical/list/formatList.ts @@ -25,12 +25,14 @@ import invariant from 'lexical/shared/invariant'; import { $createListItemNode, - $createListNode, $isListItemNode, - $isListNode, ListItemNode, +} from './LexicalListItemNode'; +import { + $createListNode, + $isListNode, ListNode, -} from './'; +} from './LexicalListNode'; import {ListType} from './LexicalListNode'; import { $getAllListItems, diff --git a/resources/js/wysiwyg/lexical/list/index.ts b/resources/js/wysiwyg/lexical/list/index.ts index 157fe79de..00e33c115 100644 --- a/resources/js/wysiwyg/lexical/list/index.ts +++ b/resources/js/wysiwyg/lexical/list/index.ts @@ -8,7 +8,10 @@ import type {SerializedListItemNode} from './LexicalListItemNode'; import type {ListType, SerializedListNode} from './LexicalListNode'; -import type {LexicalCommand} from 'lexical'; +import { + $getSelection, + $isRangeSelection, COMMAND_PRIORITY_NORMAL, INSERT_PARAGRAPH_COMMAND, LexicalCommand, LexicalEditor +} from 'lexical'; import {createCommand} from 'lexical'; @@ -20,6 +23,8 @@ import { } from './LexicalListItemNode'; import {$createListNode, $isListNode, ListNode} from './LexicalListNode'; import {$getListDepth} from './utils'; +import {mergeRegister} from "@lexical/utils"; +import {$getAncestor, INTERNAL_$isBlock} from "lexical/LexicalUtils"; export { $createListItemNode, @@ -48,3 +53,34 @@ export const INSERT_CHECK_LIST_COMMAND: LexicalCommand = createCommand( export const REMOVE_LIST_COMMAND: LexicalCommand = createCommand( 'REMOVE_LIST_COMMAND', ); + +export function registerLists(editor: LexicalEditor): () => void { + return mergeRegister( + + // Override the default insert paragraph command when within a list item + // so that new blocks are inserted as their own list items. + editor.registerCommand(INSERT_PARAGRAPH_COMMAND, () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + + const anchorNode = selection.anchor.getNode(); + const block = $getAncestor(anchorNode, INTERNAL_$isBlock)!; + const blockParent = block.getParent(); + if ($isListItemNode(blockParent)) { + const newBlock = selection.insertParagraph(); + if (newBlock) { + const newListItem = $createListItemNode(); + const newBlockSiblings = newBlock.getNextSiblings(); + newListItem.append(newBlock, ...newBlockSiblings); + blockParent.insertAfter(newListItem, false); + newListItem.selectStart(); + } + return true; + } + + return false; + }, COMMAND_PRIORITY_NORMAL), + ); +} diff --git a/resources/js/wysiwyg/lexical/list/utils.ts b/resources/js/wysiwyg/lexical/list/utils.ts index c451a4508..984e77016 100644 --- a/resources/js/wysiwyg/lexical/list/utils.ts +++ b/resources/js/wysiwyg/lexical/list/utils.ts @@ -14,10 +14,12 @@ import invariant from 'lexical/shared/invariant'; import { $createListItemNode, $isListItemNode, - $isListNode, ListItemNode, +} from './LexicalListItemNode'; +import { + $isListNode, ListNode, -} from './'; +} from './LexicalListNode'; /** * Checks the depth of listNode from the root node. diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index a5f06f147..fbc11af49 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -3,7 +3,7 @@ import { $createTextNode, $getSelection, $insertNodes, - $isParagraphNode, + $isParagraphNode, $isRangeSelection, LexicalEditor, LexicalNode } from "lexical"; @@ -16,10 +16,12 @@ import { } from "./selection"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "@lexical/rich-text/LexicalCalloutNode"; -import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; +import {$isListItemNode, $isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; import {$createLinkNode, $isLinkNode} from "@lexical/link"; import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; +import {$setBlocksType} from "@lexical/selection"; + const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { return $isHeadingNode(node) && node.getTag() === tag; @@ -62,28 +64,35 @@ export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) { } export function formatCodeBlock(editor: LexicalEditor) { - editor.getEditorState().read(() => { + editor.update(() => { const selection = $getSelection(); const lastSelection = getLastSelection(editor); const codeBlock = $getNodeFromSelection(lastSelection, $isCodeBlockNode) as (CodeBlockNode | null); if (codeBlock === null) { - editor.update(() => { - const codeBlock = $createCodeBlockNode(); - codeBlock.setCode(selection?.getTextContent() || ''); + const codeBlock = $createCodeBlockNode(); - const selectionNodes = $getBlockElementNodesInSelection(selection); - const firstSelectionNode = selectionNodes[0]; - const extraNodes = selectionNodes.slice(1); - if (firstSelectionNode) { - firstSelectionNode.replace(codeBlock); - extraNodes.forEach(n => n.remove()); - } else { - $insertNewBlockNodeAtSelection(codeBlock, true); - } + const codeLines = []; + const selectionNodes = $getBlockElementNodesInSelection(selection); + for (const node of selectionNodes) { + codeLines.push(node.getTextContent()); + } + codeBlock.setCode(codeLines.join('\n')); - $openCodeEditorForNode(editor, codeBlock); - $selectSingleNode(codeBlock); - }); + const firstSelectionNode = selectionNodes[0]; + const extraNodes = selectionNodes.slice(1); + if ($isListItemNode(firstSelectionNode)) { + firstSelectionNode.getChildren().forEach(c => c.remove()); + firstSelectionNode.append(codeBlock); + extraNodes.forEach(n => n.remove()); + } else if (firstSelectionNode) { + firstSelectionNode.replace(codeBlock); + extraNodes.forEach(n => n.remove()); + } else { + $insertNewBlockNodeAtSelection(codeBlock, true); + } + + $openCodeEditorForNode(editor, codeBlock); + $selectSingleNode(codeBlock); } else { $openCodeEditorForNode(editor, codeBlock); } diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 28050571e..e6815e250 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -17,6 +17,7 @@ import {$setBlocksType} from "@lexical/selection"; import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes"; import {CommonBlockAlignment} from "lexical/nodes/common"; +import {$isListItemNode} from "@lexical/list"; const lastSelectionByEditor = new WeakMap; @@ -76,9 +77,33 @@ export function $selectionContainsTextFormat(selection: BaseSelection | null, fo return false; } +function createNewBlockIfSelectionIsSingleListItemText(selection: BaseSelection): void { + const startEnd = selection.getStartEndPoints(); + if (!startEnd) { + return; + } + + const startBlock = $getNearestNodeBlockParent(startEnd[0].getNode()); + const endBlock = $getNearestNodeBlockParent(startEnd[1].getNode()); + const isSingleListItemTextSelection = $isListItemNode(startBlock) && startBlock.getKey() === endBlock?.getKey(); + + if (isSingleListItemTextSelection) { + const wrapper = $createParagraphNode(); + const startNode = startEnd[0].getNode(); + startNode.insertBefore(wrapper); + wrapper.append(...selection.getNodes()); + } +} + export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creator: LexicalElementNodeCreator) { const selection = $getSelection(); const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; + + const inListItem = $isListItemNode(blockElement); + if (inListItem && selection) { + createNewBlockIfSelectionIsSingleListItemText(selection); + } + if (selection && matcher(blockElement)) { $setBlocksType(selection, $createParagraphNode); } else { From 9f4afac7bc44f9692551e832b1065c606b09f795 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 12 May 2026 18:42:06 +0100 Subject: [PATCH 66/69] Lexical: Improved ability to break out of lists Updates list handling so that you can break out of a list (or move down a level) via enter on an existing empty list item at any point in the list, not just the end. Added test to cover. --- .../lexical/list/LexicalListItemNode.ts | 23 +++---- .../unit/LexicalListItemNode.test.ts | 68 ++++++++++++++++++- resources/js/wysiwyg/utils/lists.ts | 33 ++++++++- 3 files changed, 104 insertions(+), 20 deletions(-) diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index a7b04d476..2a1b698b1 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -36,6 +36,7 @@ import {$createListNode, $isListNode} from './LexicalListNode'; import {mergeLists} from './formatList'; import {isNestedListNode} from './utils'; import {el} from "../../utils/dom"; +import {$escapeListAtItem} from "../../utils/lists"; export type SerializedListItemNode = Spread< { @@ -273,21 +274,13 @@ export class ListItemNode extends ElementNode { restoreSelection = true, ): ListItemNode | ParagraphNode | null { - if (this.getTextContent().trim() === '' && this.isLastChild()) { - const list = this.getParentOrThrow(); - const parentListItem = list.getParent(); - if ($isListItemNode(parentListItem)) { - // Un-nest list item if empty nested item - parentListItem.insertAfter(this); - this.selectStart(); - return null; - } else { - // Insert empty paragraph after list if adding after last empty child - const paragraph = $createParagraphNode(); - list.insertAfter(paragraph, restoreSelection); - this.remove(); - return paragraph; - } + // If we're adding a new empty item, and coming from an empty item, + // take that as a desire to break from the current list level. + // Intended to be a bit more lenient on the last list item hence ignores whitespace, + // which allows empty items to be listed with just a space (except for last). + const textContent = this.getTextContent(); + if (textContent === '' || (textContent.trim() === '' && this.isLastChild())) { + return $escapeListAtItem(this); } const newElement = $createListItemNode( diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts index 10ff0fc66..48fa2faf8 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -9,12 +9,12 @@ import { $createParagraphNode, $createRangeSelection, - $getRoot, LexicalEditor, + $getRoot, LexicalEditor, LexicalNode, ParagraphNode, TextNode, } from 'lexical'; import { createTestContext, destroyFromContext, - expectHtmlToBeEqual, + expectHtmlToBeEqual, expectNodeShapeToMatch, html, } from 'lexical/__tests__/utils'; @@ -1200,6 +1200,70 @@ describe('LexicalListItemNode tests', () => { `, ); }); + + test('new items after empty top-level items splits the list in two, and inserts a paragraph inbetween', () => { + let newItem: LexicalNode|null = null; + const input = `
      +
    • Item A
    • +
    • +
    • Item C
    • +
    `; + + editor.updateAndCommit(() => { + const root = $getRoot(); + root.append(...$htmlToBlockNodes(editor, input)); + const list = root.getFirstChild() as ListNode; + const itemB = list.getChildAtIndex(1) as ListItemNode; + + newItem = itemB.insertNewAfter($createRangeSelection()); + }); + + expect(newItem).toBeInstanceOf(ParagraphNode); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [{type: 'listitem', children: [{text: 'Item A'}]}], + }, + { + type: 'paragraph', + }, + { + type: 'list', + children: [{type: 'listitem', children: [{text: 'Item C'}]}], + } + ]) + }); + + test('new items after last empty top-level inserts a new paragraph below, and removes the list item', () => { + let newItem: LexicalNode|null = null; + const input = `
      +
    • Item A
    • +
    • Item B
    • +
    • +
    `; + + editor.updateAndCommit(() => { + const root = $getRoot(); + root.append(...$htmlToBlockNodes(editor, input)); + const list = root.getFirstChild() as ListNode; + const itemC = list.getChildAtIndex(2) as ListItemNode; + + newItem = itemC.insertNewAfter($createRangeSelection()); + }); + + expect(newItem).toBeInstanceOf(ParagraphNode); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [{type: 'listitem', children: [{text: 'Item A'}]}, {type: 'listitem', children: [{text: 'Item B'}]}], + }, + { + type: 'paragraph', + }, + ]) + }); }); test('$createListItemNode()', async () => { diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 3deb9dfb6..cdbaba6fb 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -1,7 +1,7 @@ -import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical"; +import {$createParagraphNode, $createTextNode, $getSelection, BaseSelection, LexicalEditor, ParagraphNode, TextNode} from "lexical"; import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection"; import {$sortNodes, nodeHasInset} from "./nodes"; -import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; +import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode, ListNode} from "@lexical/list"; export function $nestListItem(node: ListItemNode): ListItemNode { @@ -186,4 +186,31 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo } $toggleSelection(editor); -} \ No newline at end of file +} + +export function $escapeListAtItem(item: ListItemNode): ParagraphNode|null { + const list = item.getParentOrThrow(); + const parentListItem = list.getParent(); + + // Un-nest list item if it's an empty nested item + if ($isListItemNode(parentListItem)) { + parentListItem.insertAfter(item); + item.selectStart(); + return null; + } + + // If we're anywhere but at the end, split the list, with following items + // moved into their own new list. + if (!item.isLastChild()) { + const afterSiblings = item.getNextSiblings(); + const newList = $createListNode(list.getListType()); + newList.append(...afterSiblings); + list.insertAfter(newList); + } + + // Insert a new empty paragraph to land on after our list + const paragraph = $createParagraphNode(); + list.insertAfter(paragraph, true); + item.remove(); + return paragraph; +} From ddb0a22504bedb121a3cbe0b7d3233704f0f2903 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 13 May 2026 16:54:45 +0100 Subject: [PATCH 67/69] Lexical: Made table cell up/down arrow nav smarter Added logic to attempt to retain x position when navigating cells up/down via arrow keys. --- .../wysiwyg/lexical/core/LexicalSelection.ts | 7 ++++ .../lexical/table/LexicalCaptionNode.ts | 1 - .../table/LexicalTableSelectionHelpers.ts | 15 ++++++- resources/js/wysiwyg/utils/selection.ts | 42 +++++++++++++++++-- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts index 2904867f0..73ce0edbb 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts @@ -2219,6 +2219,13 @@ export function $createRangeSelection(): RangeSelection { return new RangeSelection(anchor, focus, 0, ''); } +export function $createCollapsedRangeSelectionForNode(node: LexicalNode, offset: number = 0): RangeSelection { + const type = $isTextNode(node) ? 'text' : 'element'; + const anchor = $createPoint(node.getKey(), offset, type); + const focus = $createPoint(node.getKey(), offset, type); + return new RangeSelection(anchor, focus, 0, ''); +} + export function $createNodeSelection(): NodeSelection { return new NodeSelection(new Set()); } diff --git a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts index d9d83562c..5f2ef9a25 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts @@ -1,7 +1,6 @@ import { $createTextNode, DOMConversionMap, - DOMExportOutput, EditorConfig, ElementNode, LexicalEditor, diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts index 6e5e5416f..7e2d93ad9 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -72,6 +72,7 @@ import {$isTableRowNode} from './LexicalTableRowNode'; import {$isTableSelection} from './LexicalTableSelection'; import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils'; import {$selectOrCreateAdjacent} from "../../utils/nodes"; +import {$selectNodeAtXPixelOffset} from "../../utils/selection"; const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection'; @@ -1073,6 +1074,7 @@ const selectTableNodeInDirection = ( x: number, y: number, direction: Direction, + selectionOffset: number = -1 ): boolean => { const isForward = direction === 'forward'; @@ -1112,6 +1114,7 @@ const selectTableNodeInDirection = ( selectTableCellNode( tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table), false, + selectionOffset, ); } else { $selectOrCreateAdjacent(tableNode, false); @@ -1124,6 +1127,7 @@ const selectTableNodeInDirection = ( selectTableCellNode( tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table), true, + selectionOffset, ); } else { $selectOrCreateAdjacent(tableNode, true); @@ -1197,7 +1201,14 @@ function $isSelectionInTable( return false; } -function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) { +function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean, selectionOffsetPixels : number = -1) { + if (selectionOffsetPixels !== -1) { + const selection = $selectNodeAtXPixelOffset(tableCell, selectionOffsetPixels, fromStart); + if (selection) { + return; + } + } + if (fromStart) { tableCell.selectStart(); } else { @@ -1491,12 +1502,14 @@ function $handleArrowKey( tableObserver.setAnchorCellForSelection(cell); tableObserver.setFocusCellForSelection(cell, true); } else { + const selectionOffset = edgeSelectionRect.x - edgeRect.x; return selectTableNodeInDirection( tableObserver, tableNode, cords.x, cords.y, direction, + selectionOffset ); } diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index e6815e250..77aa902f0 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -1,6 +1,6 @@ import { $createNodeSelection, - $createParagraphNode, $createRangeSelection, + $createParagraphNode, $createRangeSelection, $getEditor, $getNearestNodeFromDOMNode, $getRoot, $getSelection, $isBlockElementNode, $isDecoratorNode, $isElementNode, $isParagraphNode, @@ -8,7 +8,7 @@ import { $setSelection, BaseSelection, DecoratorNode, ElementNode, LexicalEditor, - LexicalNode, + LexicalNode, RangeSelection, TextFormatType, TextNode } from "lexical"; import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; @@ -18,6 +18,7 @@ import {$setBlocksType} from "@lexical/selection"; import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes"; import {CommonBlockAlignment} from "lexical/nodes/common"; import {$isListItemNode} from "@lexical/list"; +import {$createCollapsedRangeSelectionForNode} from "lexical/LexicalSelection"; const lastSelectionByEditor = new WeakMap; @@ -300,4 +301,39 @@ export function $getDecoratorNodesInSelection(selection: BaseSelection | null): } return selection.getNodes().filter(node => $isDecoratorNode(node)); -} \ No newline at end of file +} + +/** + * Attempt to select the given node at roughly the pixel offset, relative to the left of the node. + * Returns the range selection if a selection could be made. + * Returns null if no selection can be made. + */ +export function $selectNodeAtXPixelOffset(node: LexicalNode, pixelOffset: number, targetStart: boolean = true): RangeSelection|null { + const targetDOM = $getEditor().getElementByKey(node.getKey()); + if (!targetDOM) { + return null; + } + + const targetChild = targetDOM.children[targetStart ? 0 : targetDOM.children.length - 1] || targetDOM; + const targetBounds = targetChild.getBoundingClientRect(); + const targetY = targetBounds[targetStart ? 'top' : 'bottom'] + (targetStart ? 1 : -1); + const targetX = targetBounds.x + pixelOffset; + // Temporary caretRangeFromPoint usage due to caretPositionFromPoint being only + // very recently supported in Safari + // To remove post 2026 + const caretRange = document.caretRangeFromPoint?.(targetX, targetY); + const caret = document.caretPositionFromPoint?.(targetX, targetY) + ?? (caretRange ? { offsetNode: caretRange.startContainer, offset: caretRange.startOffset } : undefined); + if (!caret) { + return null; + } + + const targetNode = $getNearestNodeFromDOMNode(caret.offsetNode); + if (!targetNode) { + return null; + } + + const rangeSelection = $createCollapsedRangeSelectionForNode(targetNode, caret.offset); + $setSelection(rangeSelection); + return rangeSelection; +} From c87abbd23f2dca8c733a8e010b42a089041fa2ae Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 14 May 2026 18:10:46 +0100 Subject: [PATCH 68/69] Images: Increased validation against related page on upload Adds validation that the related page exists, is visible, and that the user has page edit permissions for the page. Aligns with attachment permission model, and aligns across different image endpoints. Added tests to cover. Closes #6126 --- app/Config/database.php | 1 - .../Controllers/DrawioImageController.php | 14 ++-- .../Controllers/GalleryImageController.php | 18 +++-- .../Controllers/ImageGalleryApiController.php | 1 + tests/Activity/CommentMentionTest.php | 2 +- tests/Api/ContentPermissionsApiTest.php | 4 +- tests/Api/ImageGalleryApiTest.php | 28 ++++++++ .../RegeneratePermissionsCommandTest.php | 2 +- tests/ErrorTest.php | 3 +- tests/Helpers/PermissionsProvider.php | 3 +- .../Scenarios/EntityRolePermissionsTest.php | 66 +++++++++---------- tests/PublicActionTest.php | 3 +- tests/Uploads/DrawioTest.php | 24 +++++++ tests/Uploads/ImageTest.php | 39 +++++++++++ 14 files changed, 155 insertions(+), 53 deletions(-) diff --git a/app/Config/database.php b/app/Config/database.php index 6fc861312..86bae5f5b 100644 --- a/app/Config/database.php +++ b/app/Config/database.php @@ -81,7 +81,6 @@ return [ 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - // @phpstan-ignore class.notFound (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/app/Uploads/Controllers/DrawioImageController.php b/app/Uploads/Controllers/DrawioImageController.php index 8295febc1..53ae79004 100644 --- a/app/Uploads/Controllers/DrawioImageController.php +++ b/app/Uploads/Controllers/DrawioImageController.php @@ -2,6 +2,7 @@ namespace BookStack\Uploads\Controllers; +use BookStack\Entities\Queries\PageQueries; use BookStack\Exceptions\ImageUploadException; use BookStack\Http\Controller; use BookStack\Permissions\Permission; @@ -14,7 +15,8 @@ use Illuminate\Http\Request; class DrawioImageController extends Controller { public function __construct( - protected ImageRepo $imageRepo + protected ImageRepo $imageRepo, + protected PageQueries $pageQueries, ) { } @@ -53,16 +55,18 @@ class DrawioImageController extends Controller */ public function create(Request $request) { - $this->validate($request, [ + $this->checkPermission(Permission::ImageCreateAll); + $validated = $this->validate($request, [ 'image' => ['required', 'string'], 'uploaded_to' => ['required', 'integer'], ]); - $this->checkPermission(Permission::ImageCreateAll); - $imageBase64Data = $request->input('image'); + $imageBase64Data = $validated['image']; + $uploadedTo = $validated['uploaded_to']; + $targetPage = $this->pageQueries->findVisibleByIdOrFail($uploadedTo); + $this->checkOwnablePermission(Permission::PageUpdate, $targetPage); try { - $uploadedTo = $request->input('uploaded_to', 0); $image = $this->imageRepo->saveDrawing($imageBase64Data, $uploadedTo); } catch (ImageUploadException $e) { return response($e->getMessage(), 500); diff --git a/app/Uploads/Controllers/GalleryImageController.php b/app/Uploads/Controllers/GalleryImageController.php index 908322be0..ca60f9f8e 100644 --- a/app/Uploads/Controllers/GalleryImageController.php +++ b/app/Uploads/Controllers/GalleryImageController.php @@ -2,6 +2,7 @@ namespace BookStack\Uploads\Controllers; +use BookStack\Entities\Queries\PageQueries; use BookStack\Exceptions\ImageUploadException; use BookStack\Http\Controller; use BookStack\Permissions\Permission; @@ -14,7 +15,8 @@ use Illuminate\Validation\ValidationException; class GalleryImageController extends Controller { public function __construct( - protected ImageRepo $imageRepo + protected ImageRepo $imageRepo, + protected PageQueries $pageQueries, ) { } @@ -56,20 +58,26 @@ class GalleryImageController extends Controller $this->checkPermission(Permission::ImageCreateAll); try { - $this->validate($request, [ + $validated = $this->validate($request, [ 'file' => $this->getImageValidationRules(), + 'uploaded_to' => ['required', 'integer'], ]); } catch (ValidationException $exception) { - return $this->jsonError(implode("\n", $exception->errors()['file'])); + $errors = $exception->errors(); + $messages = array_merge($errors['file'] ?? [], $errors['uploaded_to'] ?? []); + return $this->jsonError(implode("\n", $messages)); } + $uploadedTo = intval($validated['uploaded_to']); + $targetPage = $this->pageQueries->findVisibleByIdOrFail($uploadedTo); + $this->checkOwnablePermission(Permission::PageUpdate, $targetPage); + new OutOfMemoryHandler(function () { return $this->jsonError(trans('errors.image_upload_memory_limit')); }); try { - $imageUpload = $request->file('file'); - $uploadedTo = $request->input('uploaded_to', 0); + $imageUpload = $validated['file']; $image = $this->imageRepo->saveNew($imageUpload, 'gallery', $uploadedTo); } catch (ImageUploadException $e) { return response($e->getMessage(), 500); diff --git a/app/Uploads/Controllers/ImageGalleryApiController.php b/app/Uploads/Controllers/ImageGalleryApiController.php index c4168a77e..6a72e4c30 100644 --- a/app/Uploads/Controllers/ImageGalleryApiController.php +++ b/app/Uploads/Controllers/ImageGalleryApiController.php @@ -75,6 +75,7 @@ class ImageGalleryApiController extends ApiController $this->checkPermission(Permission::ImageCreateAll); $data = $this->validate($request, $this->rules()['create']); $page = $this->pageQueries->findVisibleByIdOrFail($data['uploaded_to']); + $this->checkOwnablePermission(Permission::PageUpdate, $page); $image = $this->imageRepo->saveNew($data['image'], $data['type'], $page->id); diff --git a/tests/Activity/CommentMentionTest.php b/tests/Activity/CommentMentionTest.php index 7f26f8689..b9701b115 100644 --- a/tests/Activity/CommentMentionTest.php +++ b/tests/Activity/CommentMentionTest.php @@ -117,7 +117,7 @@ class CommentMentionTest extends TestCase $page = $this->entities->page(); $this->permissions->disableEntityInheritedPermissions($page); - $this->permissions->addEntityPermission($page, ['view'], $userA->roles()->first()); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $userA->roles()->first()); $this->asAdmin()->post("/comment/{$page->id}", [ 'html' => '

    Hello and

    ' diff --git a/tests/Api/ContentPermissionsApiTest.php b/tests/Api/ContentPermissionsApiTest.php index 464d62683..6a26b4032 100644 --- a/tests/Api/ContentPermissionsApiTest.php +++ b/tests/Api/ContentPermissionsApiTest.php @@ -39,7 +39,7 @@ class ContentPermissionsApiTest extends TestCase $page = $this->entities->page(); $owner = $this->users->newUser(); $role = $this->users->createRole(); - $this->permissions->addEntityPermission($page, ['view', 'delete'], $role); + $this->permissions->setEntityPermissionsForRole($page, ['view', 'delete'], $role); $this->permissions->changeEntityOwner($page, $owner); $this->permissions->setFallbackPermissions($page, ['update', 'create']); @@ -209,7 +209,7 @@ class ContentPermissionsApiTest extends TestCase public function test_update_can_clear_roles_permissions() { $page = $this->entities->page(); - $this->permissions->addEntityPermission($page, ['view'], $this->users->createRole()); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $this->users->createRole()); $page->owned_by = null; $page->save(); diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php index 07c20c834..09dba84f5 100644 --- a/tests/Api/ImageGalleryApiTest.php +++ b/tests/Api/ImageGalleryApiTest.php @@ -154,6 +154,34 @@ class ImageGalleryApiTest extends TestCase $resp->assertStatus(404); } + public function test_create_requires_update_permission_for_the_target_page() + { + $editor = $this->users->editor(); + $this->actingAsForApi($editor); + + $makeRequest = function (int $uploadedTo) { + return $this->call('POST', $this->baseEndpoint, [ + 'type' => 'gallery', + 'uploaded_to' => $uploadedTo, + 'name' => 'My awesome image!', + ], [], [ + 'image' => $this->files->uploadedImage('my-cool-image.png'), + ]); + }; + + $page = $this->entities->page(); + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $editor->roles()->first()); + + $resp = $makeRequest($page->id); + $resp->assertStatus(403); + + $this->permissions->setEntityPermissionsForRole($page, ['view', 'update'], $editor->roles()->first()); + + $resp = $makeRequest($page->id); + $resp->assertStatus(200); + } + public function test_create_has_restricted_types() { $this->actingAsApiEditor(); diff --git a/tests/Commands/RegeneratePermissionsCommandTest.php b/tests/Commands/RegeneratePermissionsCommandTest.php index 75c6c1b38..27a339fbe 100644 --- a/tests/Commands/RegeneratePermissionsCommandTest.php +++ b/tests/Commands/RegeneratePermissionsCommandTest.php @@ -16,7 +16,7 @@ class RegeneratePermissionsCommandTest extends TestCase $page = $this->entities->page(); $editor = $this->users->editor(); $role = $editor->roles()->first(); - $this->permissions->addEntityPermission($page, ['view'], $role); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $role); JointPermission::query()->truncate(); $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]); diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php index 642945d43..27c83e212 100644 --- a/tests/ErrorTest.php +++ b/tests/ErrorTest.php @@ -2,7 +2,6 @@ namespace Tests; -use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Support\Facades\Log; class ErrorTest extends TestCase @@ -44,7 +43,7 @@ class ErrorTest extends TestCase $this->actingAs($editor)->get($page->getUrl())->assertOk(); $this->permissions->disableEntityInheritedPermissions($book); - $this->permissions->addEntityPermission($page, ['view'], $editor->roles()->first()); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $editor->roles()->first()); $resp = $this->actingAs($editor)->get($book->getUrl()); $resp->assertNotFound(); diff --git a/tests/Helpers/PermissionsProvider.php b/tests/Helpers/PermissionsProvider.php index c9ae30919..9b12ee985 100644 --- a/tests/Helpers/PermissionsProvider.php +++ b/tests/Helpers/PermissionsProvider.php @@ -101,8 +101,9 @@ class PermissionsProvider $this->addEntityPermissionEntries($entity, $permissions); } - public function addEntityPermission(Entity $entity, array $actionList, Role $role) + public function setEntityPermissionsForRole(Entity $entity, array $actionList, Role $role) { + $entity->permissions()->where('role_id', '=', $role->id)->delete(); $permissionData = $this->actionListToEntityPermissionData($actionList, $role->id); $this->addEntityPermissionEntries($entity, [$permissionData]); } diff --git a/tests/Permissions/Scenarios/EntityRolePermissionsTest.php b/tests/Permissions/Scenarios/EntityRolePermissionsTest.php index 55761e08c..fa89d365c 100644 --- a/tests/Permissions/Scenarios/EntityRolePermissionsTest.php +++ b/tests/Permissions/Scenarios/EntityRolePermissionsTest.php @@ -29,8 +29,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $page = $this->entities->page(); $this->permissions->disableEntityInheritedPermissions($page); - $this->permissions->addEntityPermission($page, [], $roleA); - $this->permissions->addEntityPermission($page, ['view'], $roleB); + $this->permissions->setEntityPermissionsForRole($page, [], $roleA); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $roleB); $this->assertVisibleToUser($page, $user); } @@ -42,7 +42,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA); $this->assertVisibleToUser($page, $user); } @@ -54,7 +54,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, [], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, [], $roleA); $this->assertNotVisibleToUser($page, $user); } @@ -67,8 +67,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, [], $roleA); - $this->permissions->addEntityPermission($chapter, ['view'], $roleB); + $this->permissions->setEntityPermissionsForRole($chapter, [], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleB); $this->assertVisibleToUser($page, $user); } @@ -80,8 +80,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, [], $roleA); - $this->permissions->addEntityPermission($page, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, [], $roleA); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $roleA); $this->assertVisibleToUser($page, $user); } @@ -93,8 +93,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, ['view'], $roleA); - $this->permissions->addEntityPermission($page, [], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($page, [], $roleA); $this->assertNotVisibleToUser($page, $user); } @@ -107,8 +107,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($page, [], $roleA); - $this->permissions->addEntityPermission($chapter, ['view'], $roleB); + $this->permissions->setEntityPermissionsForRole($page, [], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleB); $this->assertVisibleToUser($page, $user); } @@ -121,8 +121,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($page, ['view'], $roleA); - $this->permissions->addEntityPermission($chapter, [], $roleB); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, [], $roleB); $this->assertVisibleToUser($page, $user); } @@ -131,7 +131,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase { [$user, $roleA] = $this->users->newUserWithRole(); $page = $this->entities->page(); - $this->permissions->addEntityPermission($page, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $roleA); $this->assertVisibleToUser($page, $user); } @@ -140,7 +140,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase { [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); $page = $this->entities->page(); - $this->permissions->addEntityPermission($page, [], $roleA); + $this->permissions->setEntityPermissionsForRole($page, [], $roleA); $this->assertNotVisibleToUser($page, $user); } @@ -150,7 +150,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase [$user, $roleA] = $this->users->newUserWithRole([], []); $page = $this->entities->pageWithinChapter(); $chapter = $page->chapter; - $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA); $this->assertVisibleToUser($page, $user); } @@ -160,7 +160,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); $page = $this->entities->pageWithinChapter(); $chapter = $page->chapter; - $this->permissions->addEntityPermission($chapter, [], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, [], $roleA); $this->assertNotVisibleToUser($page, $user); } @@ -170,7 +170,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-own']); $page = $this->entities->pageWithinChapter(); $chapter = $page->chapter; - $this->permissions->addEntityPermission($chapter, [], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, [], $roleA); $this->permissions->changeEntityOwner($page, $user); $this->assertNotVisibleToUser($page, $user); @@ -182,7 +182,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $roleB = $this->users->attachNewRole($user); $page = $this->entities->page(); - $this->permissions->addEntityPermission($page, [], $roleB); + $this->permissions->setEntityPermissionsForRole($page, [], $roleB); $this->assertNotVisibleToUser($page, $user); } @@ -194,7 +194,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $page = $this->entities->page(); $this->permissions->changeEntityOwner($page, $user); - $this->permissions->addEntityPermission($page, [], $roleB); + $this->permissions->setEntityPermissionsForRole($page, [], $roleB); $this->assertNotVisibleToUser($page, $user); } @@ -207,7 +207,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $page = $this->entities->pageWithinChapter(); $chapter = $page->chapter; - $this->permissions->addEntityPermission($chapter, [], $roleB); + $this->permissions->setEntityPermissionsForRole($chapter, [], $roleB); $this->assertNotVisibleToUser($page, $user); } @@ -220,7 +220,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->changeEntityOwner($page, $user); - $this->permissions->addEntityPermission($chapter, [], $roleB); + $this->permissions->setEntityPermissionsForRole($chapter, [], $roleB); $this->assertNotVisibleToUser($page, $user); } @@ -231,7 +231,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $page = $this->entities->page(); $this->permissions->setFallbackPermissions($page, []); - $this->permissions->addEntityPermission($page, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $roleA); $this->assertVisibleToUser($page, $user); } @@ -241,7 +241,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $page = $this->entities->page(); $this->permissions->setFallbackPermissions($page, ['view']); - $this->permissions->addEntityPermission($page, [], $roleA); + $this->permissions->setEntityPermissionsForRole($page, [], $roleA); $this->assertNotVisibleToUser($page, $user); } @@ -253,7 +253,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $page = $this->entities->page(); $this->permissions->setFallbackPermissions($page, []); - $this->permissions->addEntityPermission($page, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($page, ['view'], $roleA); $this->assertVisibleToUser($page, $user); } @@ -265,7 +265,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $page = $this->entities->page(); $this->permissions->setFallbackPermissions($page, ['view']); - $this->permissions->addEntityPermission($page, [], $roleA); + $this->permissions->setEntityPermissionsForRole($page, [], $roleA); $this->assertNotVisibleToUser($page, $user); } @@ -277,7 +277,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->setFallbackPermissions($chapter, []); - $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA); $this->assertVisibleToUser($page, $user); } @@ -289,7 +289,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->setFallbackPermissions($chapter, ['view']); - $this->permissions->addEntityPermission($chapter, [], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, [], $roleA); $this->assertNotVisibleToUser($page, $user); } @@ -302,7 +302,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->setFallbackPermissions($chapter, []); - $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA); $this->assertVisibleToUser($page, $user); } @@ -315,7 +315,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $chapter = $page->chapter; $this->permissions->setFallbackPermissions($chapter, ['view']); - $this->permissions->addEntityPermission($chapter, [], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, [], $roleA); $this->assertNotVisibleToUser($page, $user); } @@ -328,7 +328,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $this->permissions->setFallbackPermissions($chapter, []); $this->permissions->setFallbackPermissions($page, []); - $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA); $this->assertNotVisibleToUser($page, $user); } @@ -342,7 +342,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $this->permissions->setFallbackPermissions($book, []); $this->permissions->setFallbackPermissions($chapter, []); - $this->permissions->addEntityPermission($book, ['view'], $roleA); + $this->permissions->setEntityPermissionsForRole($book, ['view'], $roleA); $this->assertNotVisibleToUser($page, $user); } diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index e6fc7a6a3..5b0f5353a 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -6,7 +6,6 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Permissions\Models\RolePermission; use BookStack\Users\Models\Role; -use BookStack\Users\Models\User; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\View; @@ -173,7 +172,7 @@ class PublicActionTest extends TestCase $newRole = $this->users->attachNewRole($this->users->guest(), []); $page = $this->entities->page(); $this->permissions->disableEntityInheritedPermissions($page); - $this->permissions->addEntityPermission($page, ['view', 'update'], $newRole); + $this->permissions->setEntityPermissionsForRole($page, ['view', 'update'], $newRole); $resp = $this->get($page->getUrl()); $resp->assertOk(); diff --git a/tests/Uploads/DrawioTest.php b/tests/Uploads/DrawioTest.php index d5b3f6088..faa2a0bfb 100644 --- a/tests/Uploads/DrawioTest.php +++ b/tests/Uploads/DrawioTest.php @@ -72,6 +72,30 @@ class DrawioTest extends TestCase $this->assertTrue($testImageData === $uploadedImageData, 'Uploaded image file data does not match our test image as expected'); } + public function test_base64_upload_requires_edit_permission_to_page() + { + $page = $this->entities->page(); + $editor = $this->users->editor(); + $this->actingAs($editor); + + $this->permissions->disableEntityInheritedPermissions($page); + + $upload = function () use ($page) { + return $this->postJson('images/drawio', [ + 'uploaded_to' => $page->id, + 'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=', + ]); + }; + + $upload()->assertStatus(404); + + $this->permissions->setEntityPermissionsForRole($page, ['view'], $editor->roles()->first()); + $upload()->assertStatus(403); + + $this->permissions->setEntityPermissionsForRole($page, ['view', 'update'], $editor->roles()->first()); + $upload()->assertStatus(200); + } + public function test_drawio_url_can_be_configured() { config()->set('services.drawio', 'http://cats.com?dog=tree'); diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 1bccee2c3..ac7bf31e4 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -36,6 +36,45 @@ class ImageTest extends TestCase ]); } + public function test_image_upload_with_page_reference_requires_visibility_and_update_permissions_of_target_page() + { + $page = $this->entities->page(); + $editor = $this->users->editor(); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->actingAs($editor); + + $resp = $this->files->uploadGalleryImage($this, 'test-image.png', $page->id); + $resp->assertStatus(404); + + $this->permissions->setEntityPermissionsForRole($page, ['view'], $editor->roles()->first()); + + $resp = $this->files->uploadGalleryImage($this, 'test-image.png', $page->id); + $this->assertPermissionError($resp); + + $this->permissions->setEntityPermissionsForRole($page, ['view', 'update'], $editor->roles()->first()); + + $resp = $this->files->uploadGalleryImage($this, 'test-image.png', $page->id); + $resp->assertStatus(200); + + $this->files->deleteAtRelativePath($resp->json('path')); + } + + public function test_image_upload_with_page_reference_requires_page_to_exist() + { + $page = $this->entities->page(); + $this->entities->destroy($page); + + $editor = $this->users->editor(); + $this->actingAs($editor); + + $resp = $this->files->uploadGalleryImage($this, 'test-image.png', $page->id); + $resp->assertStatus(404); + + $resp = $this->files->uploadGalleryImage($this, 'test-image.png', 0); + $resp->assertStatus(404); + } + public function test_image_display_thumbnail_generation_does_not_increase_image_size() { $page = $this->entities->page(); From 39a14cff8f64f51fa3b4be2f4c2a7b85d7c2b600 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 17 May 2026 13:00:38 +0100 Subject: [PATCH 69/69] User MFA: Reviewed addition of reset, added tests Review of #6056 Added test coverage. --- app/Activity/ActivityType.php | 1 + app/Users/Controllers/UserController.php | 10 ++-- lang/en/activities.php | 2 + lang/en/settings.php | 8 ++-- resources/views/users/edit.blade.php | 44 ++++++++++++------ routes/web.php | 2 +- tests/User/UserManagementMfaTest.php | 59 ++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 tests/User/UserManagementMfaTest.php diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index a7f129f71..64532de17 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -46,6 +46,7 @@ class ActivityType const USER_CREATE = 'user_create'; const USER_UPDATE = 'user_update'; const USER_DELETE = 'user_delete'; + const USER_MFA_RESET = 'user_mfa_reset'; const API_TOKEN_CREATE = 'api_token_create'; const API_TOKEN_UPDATE = 'api_token_update'; diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index ba85f99fb..f73104381 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -4,6 +4,7 @@ namespace BookStack\Users\Controllers; use BookStack\Access\SocialDriverManager; use BookStack\Access\UserInviteException; +use BookStack\Activity\ActivityType; use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\UserUpdateException; use BookStack\Http\Controller; @@ -214,11 +215,14 @@ class UserController extends Controller */ public function resetMfa(Request $request, int $id) { + $this->preventAccessInDemoMode(); $this->checkPermission(Permission::UsersManage); + $user = $this->userRepo->getById($id); - // Resetear el 2FA del usuario $user->mfaValues()->delete(); - session()->flash('success', trans('settings.users_mfa_reset_success', ['userName' => $user->name])); - return redirect()->back(); + + $this->logActivity(ActivityType::USER_MFA_RESET, $user); + + return redirect("/settings/users/{$user->id}"); } } diff --git a/lang/en/activities.php b/lang/en/activities.php index 4362fc029..e9344a3d4 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -99,6 +99,8 @@ return [ 'user_update_notification' => 'User successfully updated', 'user_delete' => 'deleted user', 'user_delete_notification' => 'User successfully removed', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'created API token', diff --git a/lang/en/settings.php b/lang/en/settings.php index 920c3c1bc..d03024a89 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -264,11 +264,9 @@ return [ 'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', 'users_mfa_x_methods' => ':count method configured|:count methods configured', 'users_mfa_configure' => 'Configure Methods', - 'users_mfa_reset' => 'Reset 2FA', - 'users_mfa_reset_desc' => 'Reset and clear all configured MFA methods for :userName. They will be prompted to reconfigure on next login.', - 'users_mfa_reset_confirm' => 'Are you sure you want to reset 2FA for :userName?', - 'users_mfa_reset_success' => '2FA has been reset for :userName', - 'users_mfa_reset_error' => 'Failed to reset 2FA for :userName', + 'users_mfa_reset' => 'Reset Multi-Factor Authentication Methods', + 'users_mfa_reset_desc' => 'This will reset and clear all configured multi-factor authentication methods for this user. If multi-factor authentication is required by any of their roles, they\'ll be prompted to configure new methods on their next login.', + 'users_mfa_reset_confirm' => 'Are you sure you want to reset multi-factor authentication for this user?', // API Tokens 'user_api_token_create' => 'Create API Token', diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 64d45f503..8a4a8bcce 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -71,21 +71,35 @@
  • - @if(user()->hasSystemRole('admin')) -
    -
    -
    -
    - {{ trans('settings.users_mfa_reset') }} -

    {{ trans('settings.users_mfa_reset_desc', ['userName' => $user->name]) }}

    -
    -
    -
    id}/reset-mfa") }}" method="POST" style="display: inline;"> - @csrf - + @if($mfaMethods->count() > 0) +
    + +
    +
    +

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

    + id}/mfa") }}" method="POST" style="display: inline;"> + {{ csrf_field() }} + {{ method_field('DELETE') }} +
    diff --git a/routes/web.php b/routes/web.php index 2571da2f3..3e3b5aef3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -251,7 +251,7 @@ Route::middleware('auth')->group(function () { Route::get('/settings/users/{id}', [UserControllers\UserController::class, 'edit']); Route::put('/settings/users/{id}', [UserControllers\UserController::class, 'update']); Route::delete('/settings/users/{id}', [UserControllers\UserController::class, 'destroy']); - Route::post('/settings/users/{id}/reset-mfa', [UserControllers\UserController::class, 'resetMfa']); + Route::delete('/settings/users/{id}/mfa', [UserControllers\UserController::class, 'resetMfa']); // User Account Route::get('/my-account', [UserControllers\UserAccountController::class, 'redirect']); diff --git a/tests/User/UserManagementMfaTest.php b/tests/User/UserManagementMfaTest.php new file mode 100644 index 000000000..ea05869cb --- /dev/null +++ b/tests/User/UserManagementMfaTest.php @@ -0,0 +1,59 @@ +users->editor(); + + $resp = $this->asAdmin()->get($editor->getEditUrl()); + $resp->assertSeeText('0 methods configured'); + + MfaValue::factory()->create(['user_id' => $editor->id, 'method' => MfaValue::METHOD_BACKUP_CODES]); + + $resp = $this->get($editor->getEditUrl()); + $resp->assertSeeText('1 method configured'); + $resp->assertDontSeeText('0 methods configured'); + } + + public function test_reset_mfa_flow() + { + $editor = $this->users->editor(); + MfaValue::factory()->create(['user_id' => $editor->id, 'method' => MfaValue::METHOD_BACKUP_CODES]); + MfaValue::factory()->create(['user_id' => $editor->id, 'method' => MfaValue::METHOD_TOTP]); + + $this->assertEquals(2, $editor->mfaValues()->count()); + + $resp = $this->asAdmin()->get($editor->getEditUrl()); + $this->withHtml($resp)->assertElementContains('form[action$="/mfa"] button[type="submit"]', 'Reset'); + + $resp = $this->delete($editor->getEditUrl('/mfa')); + $resp->assertRedirect($editor->getEditUrl()); + $this->assertActivityExists(ActivityType::USER_MFA_RESET); + + $resp = $this->followRedirects($resp); + $resp->assertSee('Multi-factor authentication methods reset'); + + $this->assertEquals(0, $editor->mfaValues()->count()); + } + + public function test_users_manage_permission_required_for_mfa_reset() + { + $editor = $this->users->editor(); + $resp = $this->actingAs($editor)->delete($editor->getEditUrl('/mfa')); + $this->assertPermissionError($resp); + + $this->permissions->grantUserRolePermissions($editor, [Permission::UsersManage]); + + $resp = $this->delete($editor->getEditUrl('/mfa')); + $this->assertNotPermissionError($resp); + $resp->assertRedirect($editor->getEditUrl()); + } +}