From b6110ed3cd15cf9d0472f1ccced3dbe6fdc7db76 Mon Sep 17 00:00:00 2001 From: Joshua Booth Date: Tue, 13 May 2025 20:17:36 +0100 Subject: [PATCH 001/398] Add settings for number of books/shelves that will be displayed per page --- app/App/HomeController.php | 4 +-- app/Entities/Controllers/BookController.php | 2 +- .../Controllers/BookshelfController.php | 2 +- lang/en/settings.php | 4 +++ .../settings/categories/sorting.blade.php | 34 +++++++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/app/App/HomeController.php b/app/App/HomeController.php index 0585e0af5cc..7ec8b0af207 100644 --- a/app/App/HomeController.php +++ b/app/App/HomeController.php @@ -83,7 +83,7 @@ public function index( if ($homepageOption === 'bookshelves') { $shelves = $this->queries->shelves->visibleForListWithCover() ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) - ->paginate(18); + ->paginate(intval(setting('sorting-shelves-per-page', '18'))); $data = array_merge($commonData, ['shelves' => $shelves]); return view('home.shelves', $data); @@ -92,7 +92,7 @@ public function index( if ($homepageOption === 'books') { $books = $this->queries->books->visibleForListWithCover() ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) - ->paginate(18); + ->paginate(intval(setting('sorting-books-per-page', '18'))); $data = array_merge($commonData, ['books' => $books]); return view('home.books', $data); diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index b1685081a3d..a9f0d34dc6f 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -48,7 +48,7 @@ public function index(Request $request) $books = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) - ->paginate(18); + ->paginate(intval(setting('sorting-books-per-page', '18'))); $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false; $popular = $this->queries->popularForList()->take(4)->get(); $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index 6cedd23e7df..da84cd4470a 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -42,7 +42,7 @@ public function index(Request $request) $shelves = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) - ->paginate(18); + ->paginate(intval(setting('sorting-shelves-per-page', '18'))); $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false; $popular = $this->queries->popularForList()->get(); $new = $this->queries->visibleForList() diff --git a/lang/en/settings.php b/lang/en/settings.php index 82a4ade5df5..9e49197c007 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -76,6 +76,10 @@ // Sorting Settings 'sorting' => 'Sorting', + 'sorting_shelves_per_page' => 'Shelves Per Page', + 'sorting_shelves_per_page_desc' => 'Sets the number of shelves shown per page in shelf listings.', + 'sorting_books_per_page' => 'Books Per Page', + 'sorting_books_per_page_desc' => 'Sets the number of books shown per page in book listings.', 'sorting_book_default' => 'Default Book Sort', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 9d1d9814b96..4aff11bd2c6 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -10,6 +10,40 @@ {{ csrf_field() }} +
+
+ +

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

+
+
+ +
+
+ +
+
+ +

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

+
+
+ +
+
+
From 4c7d6420ee6ab08c5d1df5221962d84abcb89b21 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 18 Oct 2025 13:14:30 +0100 Subject: [PATCH 002/398] DB: Aligned entity structure to a common table As per PR #5800 * DB: Planned out new entity table format via migrations * DB: Created entity migration logic Made some other tweaks/fixes while testing. * DB: Added change of entity relation columns to suit new entities table * DB: Got most view queries working for new structure * Entities: Started logic change to new structure Updated base entity class, and worked through BaseRepo. Need to go through other repos next. Removed a couple of redundant interfaces as part of this since we can move the logic onto the shared ContainerData model as needed. * Entities: Been through repos to update for new format * Entities: Updated repos to act on refreshed clones Changes to core entity models are now done on clones to ensure clean state before save, and those clones are returned back if changes are needed after that action. * Entities: Updated model classes & relations for changes * Entities: Changed from *Data to a common "contents" system Added smart loading from builder instances which should hydrate with "contents()" loaded via join, while keeping the core model original. * Entities: Moved entity description/covers to own non-model classes Added back some interfaces. * Entities: Removed use of contents system for data access * Entities: Got most queries back to working order * Entities: Reverted back to data from contents, fixed various issues * Entities: Started addressing issues from tests * Entities: Addressed further tests/issues * Entities: Been through tests to get all passing in dev Fixed issues and needed test changes along the way. * Entities: Addressed phpstan errors * Entities: Reviewed TODO notes * Entities: Ensured book/shelf relation data removed on destroy * Entities: Been through API responses & adjusted field visibility * Entities: Added type index to massively improve query speed --- app/Access/Mfa/MfaSession.php | 1 - app/Console/Commands/UpdateUrlCommand.php | 6 +- .../Controllers/BookApiController.php | 7 +- .../Controllers/BookshelfApiController.php | 7 +- .../Controllers/BookshelfController.php | 1 + .../Controllers/ChapterApiController.php | 4 +- .../Controllers/ChapterController.php | 2 +- app/Entities/Controllers/PageController.php | 1 + app/Entities/EntityExistsRule.php | 20 +++ app/Entities/Models/Book.php | 91 ++++------ app/Entities/Models/BookChild.php | 5 +- app/Entities/Models/Bookshelf.php | 74 +++----- app/Entities/Models/Chapter.php | 29 ++-- app/Entities/Models/ContainerTrait.php | 26 +++ app/Entities/Models/CoverImageInterface.php | 18 -- app/Entities/Models/Entity.php | 111 +++++++++++- app/Entities/Models/EntityContainerData.php | 52 ++++++ app/Entities/Models/EntityPageData.php | 25 +++ app/Entities/Models/EntityQueryBuilder.php | 38 ++++ app/Entities/Models/EntityScope.php | 27 +++ app/Entities/Models/HasCoverInterface.php | 18 ++ .../Models/HasDefaultTemplateInterface.php | 10 ++ .../Models/HasDescriptionInterface.php | 10 ++ .../Models/HtmlDescriptionInterface.php | 17 -- app/Entities/Models/HtmlDescriptionTrait.php | 35 ---- app/Entities/Models/Page.php | 27 +-- app/Entities/Queries/BookQueries.php | 5 + app/Entities/Queries/BookshelfQueries.php | 5 + app/Entities/Queries/ChapterQueries.php | 10 +- app/Entities/Queries/EntityQueries.php | 11 ++ app/Entities/Queries/PageQueries.php | 21 ++- .../Queries/ProvidesEntityQueries.php | 7 + app/Entities/Repos/BaseRepo.php | 85 +++++---- app/Entities/Repos/BookRepo.php | 20 +-- app/Entities/Repos/BookshelfRepo.php | 7 +- app/Entities/Repos/ChapterRepo.php | 16 +- app/Entities/Repos/DeletionRepo.php | 8 +- app/Entities/Repos/PageRepo.php | 29 ++-- app/Entities/Repos/RevisionRepo.php | 4 +- app/Entities/Tools/BookContents.php | 9 +- app/Entities/Tools/Cloner.php | 6 +- app/Entities/Tools/EntityCover.php | 75 ++++++++ app/Entities/Tools/EntityDefaultTemplate.php | 60 +++++++ app/Entities/Tools/EntityHtmlDescription.php | 60 +++++++ app/Entities/Tools/HierarchyTransformer.php | 1 + app/Entities/Tools/MixedEntityListLoader.php | 10 +- app/Entities/Tools/PageContent.php | 2 +- app/Entities/Tools/TrashCan.php | 27 ++- app/Exports/ExportFormatter.php | 6 +- .../ZipExports/Models/ZipExportBook.php | 6 +- .../ZipExports/Models/ZipExportChapter.php | 2 +- app/Exports/ZipExports/ZipImportRunner.php | 8 +- app/Permissions/PermissionApplicator.php | 34 ++-- app/References/ReferenceFetcher.php | 4 +- app/References/ReferenceUpdater.php | 18 +- app/Sorting/BookSorter.php | 13 +- app/Sorting/SortRule.php | 2 +- app/Sorting/SortRuleController.php | 5 +- .../Controllers/AttachmentApiController.php | 5 +- .../Controllers/AttachmentController.php | 5 +- app/Uploads/ImageService.php | 2 +- app/Users/Controllers/UserApiController.php | 1 + app/Users/UserRepo.php | 13 +- .../Entities/Models/ChapterFactory.php | 3 +- .../factories/Entities/Models/PageFactory.php | 1 + ...625_refactor_joint_permissions_storage.php | 3 - ...025_09_15_132850_create_entities_table.php | 71 ++++++++ .../2025_09_15_134701_migrate_entity_data.php | 90 ++++++++++ ..._134751_update_entity_relation_columns.php | 114 ++++++++++++ ...25_09_15_134813_drop_old_entity_tables.php | 162 ++++++++++++++++++ database/seeders/DummyContentSeeder.php | 45 +++-- dev/api/responses/books-read.json | 2 +- dev/api/responses/pages-create.json | 2 +- dev/api/responses/pages-read.json | 2 +- dev/api/responses/recycle-bin-list.json | 2 +- resources/views/books/parts/form.blade.php | 2 +- .../views/books/parts/list-item.blade.php | 9 +- resources/views/books/show.blade.php | 6 +- resources/views/chapters/show.blade.php | 2 +- resources/views/entities/grid-item.blade.php | 2 +- resources/views/exports/book.blade.php | 2 +- resources/views/exports/chapter.blade.php | 2 +- .../exports/parts/chapter-item.blade.php | 2 +- .../form/description-html-input.blade.php | 2 +- resources/views/shelves/parts/form.blade.php | 18 +- .../views/shelves/parts/list-item.blade.php | 2 +- resources/views/shelves/show.blade.php | 6 +- tests/Api/ApiAuthTest.php | 2 +- tests/Api/BooksApiTest.php | 52 +++--- tests/Api/ChaptersApiTest.php | 12 +- tests/Api/ContentPermissionsApiTest.php | 2 +- tests/Api/PagesApiTest.php | 2 +- tests/Api/RecycleBinApiTest.php | 8 +- tests/Api/ShelvesApiTest.php | 18 +- tests/Auth/MfaConfigurationTest.php | 31 ++++ tests/Commands/UpdateUrlCommandTest.php | 4 +- tests/Entity/BookShelfTest.php | 11 +- tests/Entity/BookTest.php | 8 +- tests/Entity/ConvertTest.php | 10 +- tests/Entity/DefaultTemplateTest.php | 28 +-- tests/Entity/PageDraftTest.php | 4 +- tests/Entity/PageEditorTest.php | 2 +- tests/Entity/PageRevisionTest.php | 2 +- tests/Entity/PageTemplateTest.php | 4 +- tests/Entity/PageTest.php | 4 +- tests/Exports/MarkdownExportTest.php | 2 +- tests/Exports/ZipExportTest.php | 6 +- tests/Exports/ZipImportRunnerTest.php | 2 +- tests/Helpers/EntityProvider.php | 2 +- tests/Meta/OpenGraphTest.php | 4 +- tests/Permissions/EntityOwnerChangeTest.php | 8 +- tests/Permissions/EntityPermissionsTest.php | 6 +- tests/PublicActionTest.php | 2 +- tests/References/ReferencesTest.php | 2 +- tests/Settings/RecycleBinTest.php | 66 +++++-- tests/Sorting/BookSortTest.php | 18 +- tests/Sorting/MoveTest.php | 2 +- tests/Sorting/SortRuleTest.php | 8 +- tests/TestCase.php | 41 ++++- tests/User/UserManagementTest.php | 4 +- 120 files changed, 1598 insertions(+), 595 deletions(-) create mode 100644 app/Entities/EntityExistsRule.php create mode 100644 app/Entities/Models/ContainerTrait.php delete mode 100644 app/Entities/Models/CoverImageInterface.php create mode 100644 app/Entities/Models/EntityContainerData.php create mode 100644 app/Entities/Models/EntityPageData.php create mode 100644 app/Entities/Models/EntityQueryBuilder.php create mode 100644 app/Entities/Models/EntityScope.php create mode 100644 app/Entities/Models/HasCoverInterface.php create mode 100644 app/Entities/Models/HasDefaultTemplateInterface.php create mode 100644 app/Entities/Models/HasDescriptionInterface.php delete mode 100644 app/Entities/Models/HtmlDescriptionInterface.php delete mode 100644 app/Entities/Models/HtmlDescriptionTrait.php create mode 100644 app/Entities/Tools/EntityCover.php create mode 100644 app/Entities/Tools/EntityDefaultTemplate.php create mode 100644 app/Entities/Tools/EntityHtmlDescription.php create mode 100644 database/migrations/2025_09_15_132850_create_entities_table.php create mode 100644 database/migrations/2025_09_15_134701_migrate_entity_data.php create mode 100644 database/migrations/2025_09_15_134751_update_entity_relation_columns.php create mode 100644 database/migrations/2025_09_15_134813_drop_old_entity_tables.php diff --git a/app/Access/Mfa/MfaSession.php b/app/Access/Mfa/MfaSession.php index 09b9e53b8ae..b1285341257 100644 --- a/app/Access/Mfa/MfaSession.php +++ b/app/Access/Mfa/MfaSession.php @@ -11,7 +11,6 @@ class MfaSession */ public function isRequiredForUser(User $user): bool { - // TODO - Test both these cases return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user); } diff --git a/app/Console/Commands/UpdateUrlCommand.php b/app/Console/Commands/UpdateUrlCommand.php index 71f0b92fe41..fd86e070667 100644 --- a/app/Console/Commands/UpdateUrlCommand.php +++ b/app/Console/Commands/UpdateUrlCommand.php @@ -45,10 +45,8 @@ public function handle(Connection $db): int $columnsToUpdateByTable = [ 'attachments' => ['path'], - 'pages' => ['html', 'text', 'markdown'], - 'chapters' => ['description_html'], - 'books' => ['description_html'], - 'bookshelves' => ['description_html'], + 'entity_page_data' => ['html', 'text', 'markdown'], + 'entity_container_data' => ['description_html'], 'page_revisions' => ['html', 'text', 'markdown'], 'images' => ['url'], 'settings' => ['value'], diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index 5baea163fd6..807f5a69c8f 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -122,9 +122,10 @@ protected function forJsonDisplay(Book $book): Book $book = clone $book; $book->unsetRelations()->refresh(); - $book->load(['tags', 'cover']); - $book->makeVisible('description_html') - ->setAttribute('description_html', $book->descriptionHtml()); + $book->load(['tags']); + $book->makeVisible(['cover', 'description_html']) + ->setAttribute('description_html', $book->descriptionInfo()->getHtml()) + ->setAttribute('cover', $book->coverInfo()->getImage()); return $book; } diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php index f4bd394a9e7..735742060c5 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -116,9 +116,10 @@ protected function forJsonDisplay(Bookshelf $shelf): Bookshelf $shelf = clone $shelf; $shelf->unsetRelations()->refresh(); - $shelf->load(['tags', 'cover']); - $shelf->makeVisible('description_html') - ->setAttribute('description_html', $shelf->descriptionHtml()); + $shelf->load(['tags']); + $shelf->makeVisible(['cover', 'description_html']) + ->setAttribute('description_html', $shelf->descriptionInfo()->getHtml()) + ->setAttribute('cover', $shelf->coverInfo()->getImage()); return $shelf; } diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index f47742ffaa6..8d7ffb8f9b0 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -116,6 +116,7 @@ public function show(Request $request, ActivityQueries $activities, string $slug ]); $sort = $listOptions->getSort(); + $sortedVisibleShelfBooks = $shelf->visibleBooks() ->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder()) ->get() diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php index 80eab7bb8c1..6aa62f887c8 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -104,7 +104,7 @@ public function update(Request $request, string $id) $chapter = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); - if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) { + if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) { $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); try { @@ -144,7 +144,7 @@ protected function forJsonDisplay(Chapter $chapter): Chapter $chapter->load(['tags']); $chapter->makeVisible('description_html'); - $chapter->setAttribute('description_html', $chapter->descriptionHtml()); + $chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml()); /** @var Book $book */ $book = $chapter->book()->first(); diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index 9335e0a7034..a1af29de269 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -130,7 +130,7 @@ public function update(Request $request, string $bookSlug, string $chapterSlug) $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); - $this->chapterRepo->update($chapter, $validated); + $chapter = $this->chapterRepo->update($chapter, $validated); return redirect($chapter->getUrl()); } diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index 67ecb0bb377..603d015ef4a 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -120,6 +120,7 @@ public function store(Request $request, string $bookSlug, int $pageId) $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], ]); + $draftPage = $this->queries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent()); diff --git a/app/Entities/EntityExistsRule.php b/app/Entities/EntityExistsRule.php new file mode 100644 index 00000000000..da210544611 --- /dev/null +++ b/app/Entities/EntityExistsRule.php @@ -0,0 +1,20 @@ +where('type', $this->type); + return $existsRule->__toString(); + } +} diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 5f54e0f6af6..afd50797b15 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -2,9 +2,10 @@ namespace BookStack\Entities\Models; +use BookStack\Entities\Tools\EntityCover; +use BookStack\Entities\Tools\EntityDefaultTemplate; use BookStack\Sorting\SortRule; use BookStack\Uploads\Image; -use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -15,26 +16,25 @@ * Class Book. * * @property string $description + * @property string $description_html * @property int $image_id * @property ?int $default_template_id * @property ?int $sort_rule_id - * @property Image|null $cover * @property \Illuminate\Database\Eloquent\Collection $chapters * @property \Illuminate\Database\Eloquent\Collection $pages * @property \Illuminate\Database\Eloquent\Collection $directPages * @property \Illuminate\Database\Eloquent\Collection $shelves - * @property ?Page $defaultTemplate - * @property ?SortRule $sortRule + * @property ?SortRule $sortRule */ -class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterface +class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface { use HasFactory; - use HtmlDescriptionTrait; + use ContainerTrait; public float $searchFactor = 1.2; + protected $hidden = ['pivot', 'deleted_at', 'description_html', 'entity_id', 'entity_type', 'chapter_id', 'book_id', 'priority']; protected $fillable = ['name']; - protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html']; /** * Get the url for this book. @@ -44,55 +44,6 @@ public function getUrl(string $path = ''): string return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')])); } - /** - * Returns book cover image, if book cover not exists return default cover image. - */ - public function getBookCover(int $width = 440, int $height = 250): string - { - $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; - if (!$this->image_id || !$this->cover) { - return $default; - } - - try { - return $this->cover->getThumb($width, $height, false) ?? $default; - } catch (Exception $err) { - return $default; - } - } - - /** - * Get the cover image of the book. - */ - public function cover(): BelongsTo - { - return $this->belongsTo(Image::class, 'image_id'); - } - - /** - * Get the type of the image model that is used when storing a cover image. - */ - public function coverImageTypeKey(): string - { - return 'cover_book'; - } - - /** - * Get the Page that is used as default template for newly created pages within this Book. - */ - public function defaultTemplate(): BelongsTo - { - return $this->belongsTo(Page::class, 'default_template_id'); - } - - /** - * Get the sort set assigned to this book, if existing. - */ - public function sortRule(): BelongsTo - { - return $this->belongsTo(SortRule::class); - } - /** * Get all pages within this book. * @return HasMany @@ -107,7 +58,7 @@ public function pages(): HasMany */ public function directPages(): HasMany { - return $this->pages()->where('chapter_id', '=', '0'); + return $this->pages()->whereNull('chapter_id'); } /** @@ -116,7 +67,8 @@ public function directPages(): HasMany */ public function chapters(): HasMany { - return $this->hasMany(Chapter::class); + return $this->hasMany(Chapter::class) + ->where('type', '=', 'chapter'); } /** @@ -137,4 +89,27 @@ public function getDirectVisibleChildren(): Collection return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft'); } + + public function defaultTemplate(): EntityDefaultTemplate + { + return new EntityDefaultTemplate($this); + } + + public function cover(): BelongsTo + { + return $this->belongsTo(Image::class, 'image_id'); + } + + public function coverInfo(): EntityCover + { + return new EntityCover($this); + } + + /** + * Get the sort rule assigned to this container, if existing. + */ + public function sortRule(): BelongsTo + { + return $this->belongsTo(SortRule::class); + } } diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php index ad54fb926a9..4a2e52aedd5 100644 --- a/app/Entities/Models/BookChild.php +++ b/app/Entities/Models/BookChild.php @@ -3,7 +3,6 @@ namespace BookStack\Entities\Models; use BookStack\References\ReferenceUpdater; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -27,13 +26,13 @@ public function book(): BelongsTo /** * Change the book that this entity belongs to. */ - public function changeBook(int $newBookId): Entity + public function changeBook(int $newBookId): self { $oldUrl = $this->getUrl(); $this->book_id = $newBookId; + $this->unsetRelation('book'); $this->refreshSlug(); $this->save(); - $this->refresh(); if ($oldUrl !== $this->getUrl()) { app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl); diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index 9ae52abcb0a..42dcc8f8f2c 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -2,34 +2,34 @@ namespace BookStack\Entities\Models; +use BookStack\Entities\Tools\EntityCover; use BookStack\Uploads\Image; -use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionInterface +/** + * @property string $description + * @property string $description_html + */ +class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface { use HasFactory; - use HtmlDescriptionTrait; - - protected $table = 'bookshelves'; + use ContainerTrait; public float $searchFactor = 1.2; - protected $fillable = ['name', 'description', 'image_id']; - - protected $hidden = ['image_id', 'deleted_at', 'description_html']; + protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id']; + protected $fillable = ['name']; /** * Get the books in this shelf. - * Should not be used directly since does not take into account permissions. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * Should not be used directly since it does not take into account permissions. */ - public function books() + public function books(): BelongsToMany { return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id') + ->select(['entities.*', 'entity_container_data.*']) ->withPivot('order') ->orderBy('order', 'asc'); } @@ -50,41 +50,6 @@ public function getUrl(string $path = ''): string return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')])); } - /** - * Returns shelf cover image, if cover not exists return default cover image. - */ - public function getBookCover(int $width = 440, int $height = 250): string - { - // TODO - Make generic, focused on books right now, Perhaps set-up a better image - $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; - if (!$this->image_id || !$this->cover) { - return $default; - } - - try { - return $this->cover->getThumb($width, $height, false) ?? $default; - } catch (Exception $err) { - return $default; - } - } - - /** - * Get the cover image of the shelf. - * @return BelongsTo - */ - public function cover(): BelongsTo - { - return $this->belongsTo(Image::class, 'image_id'); - } - - /** - * Get the type of the image model that is used when storing a cover image. - */ - public function coverImageTypeKey(): string - { - return 'cover_bookshelf'; - } - /** * Check if this shelf contains the given book. */ @@ -96,7 +61,7 @@ public function contains(Book $book): bool /** * Add a book to the end of this shelf. */ - public function appendBook(Book $book) + public function appendBook(Book $book): void { if ($this->contains($book)) { return; @@ -106,12 +71,13 @@ public function appendBook(Book $book) $this->books()->attach($book->id, ['order' => $maxOrder + 1]); } - /** - * Get a visible shelf by its slug. - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - */ - public static function getBySlug(string $slug): self + public function coverInfo(): EntityCover { - return static::visible()->where('slug', '=', $slug)->firstOrFail(); + return new EntityCover($this); + } + + public function cover(): BelongsTo + { + return $this->belongsTo(Image::class, 'image_id'); } } diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index d70a49e7a98..2dd4cb77f05 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -2,27 +2,25 @@ namespace BookStack\Entities\Models; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use BookStack\Entities\Tools\EntityDefaultTemplate; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Collection; /** - * Class Chapter. - * * @property Collection $pages * @property ?int $default_template_id - * @property ?Page $defaultTemplate + * @property string $description + * @property string $description_html */ -class Chapter extends BookChild implements HtmlDescriptionInterface +class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface { use HasFactory; - use HtmlDescriptionTrait; + use ContainerTrait; public float $searchFactor = 1.2; - - protected $fillable = ['name', 'description', 'priority']; - protected $hidden = ['pivot', 'deleted_at', 'description_html']; + protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id']; + protected $fillable = ['name', 'priority']; /** * Get the pages that this chapter contains. @@ -50,14 +48,6 @@ public function getUrl(string $path = ''): string return url('/' . implode('/', $parts)); } - /** - * Get the Page that is used as default template for newly created pages within this Chapter. - */ - public function defaultTemplate(): BelongsTo - { - return $this->belongsTo(Page::class, 'default_template_id'); - } - /** * Get the visible pages in this chapter. * @return Collection @@ -70,4 +60,9 @@ public function getVisiblePages(): Collection ->orderBy('priority', 'asc') ->get(); } + + public function defaultTemplate(): EntityDefaultTemplate + { + return new EntityDefaultTemplate($this); + } } diff --git a/app/Entities/Models/ContainerTrait.php b/app/Entities/Models/ContainerTrait.php new file mode 100644 index 00000000000..9ef5ca8d43a --- /dev/null +++ b/app/Entities/Models/ContainerTrait.php @@ -0,0 +1,26 @@ + + */ + public function relatedData(): HasOne + { + return $this->hasOne(EntityContainerData::class, 'entity_id', 'id') + ->where('entity_type', '=', $this->getMorphClass()); + } +} diff --git a/app/Entities/Models/CoverImageInterface.php b/app/Entities/Models/CoverImageInterface.php deleted file mode 100644 index 5f781fe02ab..00000000000 --- a/app/Entities/Models/CoverImageInterface.php +++ /dev/null @@ -1,18 +0,0 @@ -relatedData()->firstOrNew(); + $contentFields = $this->getContentsAttributes(); + + foreach ($contentFields as $key => $value) { + $contents->setAttribute($key, $value); + unset($this->attributes[$key]); + } + + $this->setAttribute('type', $this->getMorphClass()); + $result = parent::save($options); + $contentsResult = true; + + if ($result && $contents->isDirty()) { + $contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()]; + $contents->forceFill($contentsFillData); + $contentsResult = $contents->save(); + $this->touch(); + } + + $this->forceFill($contentFields); + + return $result && $contentsResult; + } + + /** + * Check if this item is a container item. + */ + public function isContainer(): bool + { + return $this instanceof Bookshelf || + $this instanceof Book || + $this instanceof Chapter; + } + /** * Get the entities that are visible to the current user. */ @@ -91,8 +159,8 @@ public function scopeVisible(Builder $query): Builder public function scopeWithLastView(Builder $query) { $viewedAtQuery = View::query()->select('updated_at') - ->whereColumn('viewable_id', '=', $this->getTable() . '.id') - ->where('viewable_type', '=', $this->getMorphClass()) + ->whereColumn('viewable_id', '=', 'entities.id') + ->whereColumn('viewable_type', '=', 'entities.type') ->where('user_id', '=', user()->id) ->take(1); @@ -102,11 +170,12 @@ public function scopeWithLastView(Builder $query) /** * Query scope to get the total view count of the entities. */ - public function scopeWithViewCount(Builder $query) + public function scopeWithViewCount(Builder $query): void { $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count') - ->whereColumn('viewable_id', '=', $this->getTable() . '.id') - ->where('viewable_type', '=', $this->getMorphClass())->take(1); + ->whereColumn('viewable_id', '=', 'entities.id') + ->whereColumn('viewable_type', '=', 'entities.type') + ->take(1); $query->addSelect(['view_count' => $viewCountQuery]); } @@ -162,7 +231,8 @@ public function views(): MorphMany */ public function tags(): MorphMany { - return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc'); + return $this->morphMany(Tag::class, 'entity') + ->orderBy('order', 'asc'); } /** @@ -184,7 +254,7 @@ public function searchTerms(): MorphMany } /** - * Get this entities restrictions. + * Get this entities assigned permissions. */ public function permissions(): MorphMany { @@ -267,7 +337,7 @@ public static function getType(): string } /** - * Gets a limited-length version of the entities name. + * Gets a limited-length version of the entity name. */ public function getShortName(int $length = 25): string { @@ -377,4 +447,27 @@ public function logDescriptor(): string { return "({$this->id}) {$this->name}"; } + + /** + * @return HasOne + */ + abstract public function relatedData(): HasOne; + + /** + * Get the attributes that are intended for the related contents model. + * @return array + */ + protected function getContentsAttributes(): array + { + $contentFields = []; + $contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class; + + foreach ($this->attributes as $key => $value) { + if (in_array($key, $contentModel::$fields)) { + $contentFields[$key] = $value; + } + } + + return $contentFields; + } } diff --git a/app/Entities/Models/EntityContainerData.php b/app/Entities/Models/EntityContainerData.php new file mode 100644 index 00000000000..21bace7513f --- /dev/null +++ b/app/Entities/Models/EntityContainerData.php @@ -0,0 +1,52 @@ +where($this->getKeyName(), '=', $this->getKeyForSaveQuery()) + ->where('entity_type', '=', $this->entity_type); + + return $query; + } + + /** + * Override the default set keys for a select query method to make it work with composite keys. + */ + protected function setKeysForSelectQuery($query): Builder + { + $query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery()) + ->where('entity_type', '=', $this->entity_type); + + return $query; + } +} diff --git a/app/Entities/Models/EntityPageData.php b/app/Entities/Models/EntityPageData.php new file mode 100644 index 00000000000..a98b1a9823c --- /dev/null +++ b/app/Entities/Models/EntityPageData.php @@ -0,0 +1,25 @@ +withGlobalScope('entity', new EntityScope()); + } + + public function withoutGlobalScope($scope): static + { + // Prevent removal of the entity scope + if ($scope === 'entity') { + return $this; + } + + return parent::withoutGlobalScope($scope); + } + + /** + * Override the default forceDelete method to add type filter onto the query + * since it specifically ignores scopes by default. + */ + public function forceDelete() + { + return $this->query->where('type', '=', $this->model->getMorphClass())->delete(); + } +} diff --git a/app/Entities/Models/EntityScope.php b/app/Entities/Models/EntityScope.php new file mode 100644 index 00000000000..deb10c5ecbc --- /dev/null +++ b/app/Entities/Models/EntityScope.php @@ -0,0 +1,27 @@ +where('type', '=', $model->getMorphClass()); + if ($model instanceof Page) { + $builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', 'entities.id'); + } else { + $builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model) { + $join->on('entity_container_data.entity_id', '=', 'entities.id') + ->where('entity_container_data.entity_type', '=', $model->getMorphClass()); + }); + } + } +} diff --git a/app/Entities/Models/HasCoverInterface.php b/app/Entities/Models/HasCoverInterface.php new file mode 100644 index 00000000000..a4e79e9004d --- /dev/null +++ b/app/Entities/Models/HasCoverInterface.php @@ -0,0 +1,18 @@ + + */ + public function cover(): BelongsTo; +} diff --git a/app/Entities/Models/HasDefaultTemplateInterface.php b/app/Entities/Models/HasDefaultTemplateInterface.php new file mode 100644 index 00000000000..f3af0da48ab --- /dev/null +++ b/app/Entities/Models/HasDefaultTemplateInterface.php @@ -0,0 +1,10 @@ +description_html ?: '

' . nl2br(e($this->description)) . '

'; - if ($raw) { - return $html; - } - - return HtmlContentFilter::removeScriptsFromHtmlString($html); - } - - public function setDescriptionHtml(string $html, string|null $plaintext = null): void - { - $this->description_html = $html; - - if ($plaintext !== null) { - $this->description = $plaintext; - } - - if (empty($html) && !empty($plaintext)) { - $this->description_html = $this->descriptionHtml(); - } - } -} diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index 499ef4d7288..88c59bd1bd0 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -3,7 +3,6 @@ namespace BookStack\Entities\Models; use BookStack\Entities\Tools\PageContent; -use BookStack\Entities\Tools\PageEditorType; use BookStack\Permissions\PermissionApplicator; use BookStack\Uploads\Attachment; use Illuminate\Database\Eloquent\Builder; @@ -15,7 +14,7 @@ /** * Class Page. - * + * @property EntityPageData $pageData * @property int $chapter_id * @property string $html * @property string $markdown @@ -33,12 +32,10 @@ class Page extends BookChild { use HasFactory; - protected $fillable = ['name', 'priority']; - public string $textField = 'text'; public string $htmlField = 'html'; - - protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at']; + protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at', 'entity_id', 'entity_type']; + protected $fillable = ['name', 'priority']; protected $casts = [ 'draft' => 'boolean', @@ -57,10 +54,8 @@ public function scopeVisible(Builder $query): Builder /** * Get the chapter that this page is in, If applicable. - * - * @return BelongsTo */ - public function chapter() + public function chapter(): BelongsTo { return $this->belongsTo(Chapter::class); } @@ -107,10 +102,8 @@ public function allRevisions(): HasMany /** * Get the attachments assigned to this page. - * - * @return HasMany */ - public function attachments() + public function attachments(): HasMany { return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc'); } @@ -139,8 +132,16 @@ public function forJsonDisplay(): self $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']); $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown'])); $refreshed->setAttribute('raw_html', $refreshed->html); - $refreshed->html = (new PageContent($refreshed))->render(); + $refreshed->setAttribute('html', (new PageContent($refreshed))->render()); return $refreshed; } + + /** + * @return HasOne + */ + public function relatedData(): HasOne + { + return $this->hasOne(EntityPageData::class, 'page_id', 'id'); + } } diff --git a/app/Entities/Queries/BookQueries.php b/app/Entities/Queries/BookQueries.php index 2492f81318a..a466f37bc0f 100644 --- a/app/Entities/Queries/BookQueries.php +++ b/app/Entities/Queries/BookQueries.php @@ -55,6 +55,11 @@ public function visibleForList(): Builder ->select(static::$listAttributes); } + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + public function visibleForListWithCover(): Builder { return $this->visibleForList()->with('cover'); diff --git a/app/Entities/Queries/BookshelfQueries.php b/app/Entities/Queries/BookshelfQueries.php index 842011a8781..3fe0a2afcef 100644 --- a/app/Entities/Queries/BookshelfQueries.php +++ b/app/Entities/Queries/BookshelfQueries.php @@ -60,6 +60,11 @@ public function visibleForList(): Builder return $this->start()->scopes('visible')->select(static::$listAttributes); } + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + public function visibleForListWithCover(): Builder { return $this->visibleForList()->with('cover'); diff --git a/app/Entities/Queries/ChapterQueries.php b/app/Entities/Queries/ChapterQueries.php index 9bf0ff65bfd..9ddeb9b5896 100644 --- a/app/Entities/Queries/ChapterQueries.php +++ b/app/Entities/Queries/ChapterQueries.php @@ -65,8 +65,14 @@ public function visibleForList(): Builder ->scopes('visible') ->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) { $builder->select('slug') - ->from('books') - ->whereColumn('books.id', '=', 'chapters.book_id'); + ->from('entities as books') + ->where('type', '=', 'book') + ->whereColumn('books.id', '=', 'entities.book_id'); }])); } + + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } } diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php index 0d2cd7acf64..a7a037916d5 100644 --- a/app/Entities/Queries/EntityQueries.php +++ b/app/Entities/Queries/EntityQueries.php @@ -43,6 +43,17 @@ public function visibleForList(string $entityType): Builder return $queries->visibleForList(); } + /** + * Start a query of visible entities of the given type, + * suitable for using the contents of the items. + * @return Builder + */ + public function visibleForContent(string $entityType): Builder + { + $queries = $this->getQueriesForType($entityType); + return $queries->visibleForContent(); + } + protected function getQueriesForType(string $type): ProvidesEntityQueries { $queries = match ($type) { diff --git a/app/Entities/Queries/PageQueries.php b/app/Entities/Queries/PageQueries.php index ee7b201bc1a..f4ecee2dc08 100644 --- a/app/Entities/Queries/PageQueries.php +++ b/app/Entities/Queries/PageQueries.php @@ -13,7 +13,7 @@ class PageQueries implements ProvidesEntityQueries { protected static array $contentAttributes = [ 'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', - 'template', 'html', 'text', 'created_at', 'updated_at', 'priority', + 'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority', 'created_by', 'updated_by', 'owned_by', ]; protected static array $listAttributes = [ @@ -82,6 +82,14 @@ public function visibleForList(): Builder ->select($this->mergeBookSlugForSelect(static::$listAttributes)); } + /** + * @return Builder + */ + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + public function visibleForChapterList(int $chapterId): Builder { return $this->visibleForList() @@ -104,18 +112,19 @@ public function currentUserDraftsForList(): Builder ->where('created_by', '=', user()->id); } - public function visibleTemplates(): Builder + public function visibleTemplates(bool $includeContents = false): Builder { - return $this->visibleForList() - ->where('template', '=', true); + $base = $includeContents ? $this->visibleWithContents() : $this->visibleForList(); + return $base->where('template', '=', true); } protected function mergeBookSlugForSelect(array $columns): array { return array_merge($columns, ['book_slug' => function ($builder) { $builder->select('slug') - ->from('books') - ->whereColumn('books.id', '=', 'pages.book_id'); + ->from('entities as books') + ->where('type', '=', 'book') + ->whereColumn('books.id', '=', 'entities.book_id'); }]); } } diff --git a/app/Entities/Queries/ProvidesEntityQueries.php b/app/Entities/Queries/ProvidesEntityQueries.php index 79fc64b3ab8..674e96afa24 100644 --- a/app/Entities/Queries/ProvidesEntityQueries.php +++ b/app/Entities/Queries/ProvidesEntityQueries.php @@ -35,4 +35,11 @@ public function findVisibleById(int $id): ?Entity; * @return Builder */ public function visibleForList(): Builder; + + /** + * Start a query for items that are visible, with selection + * configured for using the content of the items found. + * @return Builder + */ + public function visibleForContent(): Builder; } diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index bfc01a58d1d..fd88625cd9a 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -3,13 +3,10 @@ namespace BookStack\Entities\Repos; use BookStack\Activity\TagRepo; -use BookStack\Entities\Models\Book; use BookStack\Entities\Models\BookChild; -use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\HasCoverInterface; +use BookStack\Entities\Models\HasDescriptionInterface; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\CoverImageInterface; -use BookStack\Entities\Models\HtmlDescriptionInterface; -use BookStack\Entities\Models\HtmlDescriptionTrait; use BookStack\Entities\Queries\PageQueries; use BookStack\Exceptions\ImageUploadException; use BookStack\References\ReferenceStore; @@ -33,17 +30,25 @@ public function __construct( /** * Create a new entity in the system. + * @template T of Entity + * @param T $entity + * @return T */ - public function create(Entity $entity, array $input) + public function create(Entity $entity, array $input): Entity { + $entity = (clone $entity)->refresh(); $entity->fill($input); - $this->updateDescription($entity, $input); $entity->forceFill([ 'created_by' => user()->id, 'updated_by' => user()->id, 'owned_by' => user()->id, ]); $entity->refreshSlug(); + + if ($entity instanceof HasDescriptionInterface) { + $this->updateDescription($entity, $input); + } + $entity->save(); if (isset($input['tags'])) { @@ -53,24 +58,33 @@ public function create(Entity $entity, array $input) $entity->refresh(); $entity->rebuildPermissions(); $entity->indexForSearch(); + $this->referenceStore->updateForEntity($entity); + + return $entity; } /** * Update the given entity. + * @template T of Entity + * @param T $entity + * @return T */ - public function update(Entity $entity, array $input) + public function update(Entity $entity, array $input): Entity { $oldUrl = $entity->getUrl(); $entity->fill($input); - $this->updateDescription($entity, $input); $entity->updated_by = user()->id; if ($entity->isDirty('name') || empty($entity->slug)) { $entity->refreshSlug(); } + if ($entity instanceof HasDescriptionInterface) { + $this->updateDescription($entity, $input); + } + $entity->save(); if (isset($input['tags'])) { @@ -84,59 +98,35 @@ public function update(Entity $entity, array $input) if ($oldUrl !== $entity->getUrl()) { $this->referenceUpdater->updateEntityReferences($entity, $oldUrl); } + + return $entity; } /** - * Update the given items' cover image, or clear it. + * Update the given items' cover image or clear it. * * @throws ImageUploadException * @throws \Exception */ - public function updateCoverImage(Entity&CoverImageInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false) + public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void { if ($coverImage) { - $imageType = $entity->coverImageTypeKey(); - $this->imageRepo->destroyImage($entity->cover()->first()); + $imageType = 'cover_' . $entity->type; + $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); $image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true); - $entity->cover()->associate($image); + $entity->coverInfo()->setImage($image); $entity->save(); } if ($removeImage) { - $this->imageRepo->destroyImage($entity->cover()->first()); - $entity->cover()->dissociate(); - $entity->save(); - } - } - - /** - * Update the default page template used for this item. - * Checks that, if changing, the provided value is a valid template and the user - * has visibility of the provided page template id. - */ - public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void - { - $changing = $templateId !== intval($entity->default_template_id); - if (!$changing) { - return; - } - - if ($templateId === 0) { - $entity->default_template_id = null; + $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); + $entity->coverInfo()->setImage(null); $entity->save(); - return; } - - $templateExists = $this->pageQueries->visibleTemplates() - ->where('id', '=', $templateId) - ->exists(); - - $entity->default_template_id = $templateExists ? $templateId : null; - $entity->save(); } /** - * Sort the parent of the given entity, if any auto sort actions are set for it. + * Sort the parent of the given entity if any auto sort actions are set for it. * Typically ran during create/update/insert events. */ public function sortParent(Entity $entity): void @@ -147,19 +137,22 @@ public function sortParent(Entity $entity): void } } + /** + * Update the description of the given entity from input data. + */ protected function updateDescription(Entity $entity, array $input): void { - if (!($entity instanceof HtmlDescriptionInterface)) { + if (!$entity instanceof HasDescriptionInterface) { return; } if (isset($input['description_html'])) { - $entity->setDescriptionHtml( + $entity->descriptionInfo()->set( HtmlDescriptionFilter::filterFromString($input['description_html']), html_entity_decode(strip_tags($input['description_html'])) ); } else if (isset($input['description'])) { - $entity->setDescriptionHtml('', $input['description']); + $entity->descriptionInfo()->set('', $input['description']); } } } diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 6d28d5d6aab..b4244b9bb77 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -30,19 +30,18 @@ public function __construct( public function create(array $input): Book { return (new DatabaseTransaction(function () use ($input) { - $book = new Book(); - - $this->baseRepo->create($book, $input); + $book = $this->baseRepo->create(new Book(), $input); $this->baseRepo->updateCoverImage($book, $input['image'] ?? null); - $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null)); + $book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); Activity::add(ActivityType::BOOK_CREATE, $book); $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { $book->sort_rule_id = $defaultBookSortSetting; - $book->save(); } + $book->save(); + return $book; }))->run(); } @@ -52,28 +51,29 @@ public function create(array $input): Book */ public function update(Book $book, array $input): Book { - $this->baseRepo->update($book, $input); + $book = $this->baseRepo->update($book, $input); if (array_key_exists('default_template_id', $input)) { - $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'])); + $book->defaultTemplate()->setFromId(intval($input['default_template_id'])); } if (array_key_exists('image', $input)) { $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null); } + $book->save(); Activity::add(ActivityType::BOOK_UPDATE, $book); return $book; } /** - * Update the given book's cover image, or clear it. + * Update the given book's cover image or clear it. * * @throws ImageUploadException * @throws Exception */ - public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false) + public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void { $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage); } @@ -83,7 +83,7 @@ public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $re * * @throws Exception */ - public function destroy(Book $book) + public function destroy(Book $book): void { $this->trashCan->softDestroyBook($book); Activity::add(ActivityType::BOOK_DELETE, $book); diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index b870ec37747..bb84b51fd5e 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -25,8 +25,7 @@ public function __construct( public function create(array $input, array $bookIds): Bookshelf { return (new DatabaseTransaction(function () use ($input, $bookIds) { - $shelf = new Bookshelf(); - $this->baseRepo->create($shelf, $input); + $shelf = $this->baseRepo->create(new Bookshelf(), $input); $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null); $this->updateBooks($shelf, $bookIds); Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); @@ -39,7 +38,7 @@ public function create(array $input, array $bookIds): Bookshelf */ public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf { - $this->baseRepo->update($shelf, $input); + $shelf = $this->baseRepo->update($shelf, $input); if (!is_null($bookIds)) { $this->updateBooks($shelf, $bookIds); @@ -96,7 +95,7 @@ protected function updateBooks(Bookshelf $shelf, array $bookIds): void * * @throws Exception */ - public function destroy(Bookshelf $shelf) + public function destroy(Bookshelf $shelf): void { $this->trashCan->softDestroyShelf($shelf); Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf); diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index 5d4b5297841..d5feb30fdfd 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -33,8 +33,11 @@ public function create(array $input, Book $parentBook): Chapter $chapter = new Chapter(); $chapter->book_id = $parentBook->id; $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; - $this->baseRepo->create($chapter, $input); - $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null)); + + $chapter = $this->baseRepo->create($chapter, $input); + $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); + + $chapter->save(); Activity::add(ActivityType::CHAPTER_CREATE, $chapter); $this->baseRepo->sortParent($chapter); @@ -48,12 +51,13 @@ public function create(array $input, Book $parentBook): Chapter */ public function update(Chapter $chapter, array $input): Chapter { - $this->baseRepo->update($chapter, $input); + $chapter = $this->baseRepo->update($chapter, $input); if (array_key_exists('default_template_id', $input)) { - $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'])); + $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'])); } + $chapter->save(); Activity::add(ActivityType::CHAPTER_UPDATE, $chapter); $this->baseRepo->sortParent($chapter); @@ -66,7 +70,7 @@ public function update(Chapter $chapter, array $input): Chapter * * @throws Exception */ - public function destroy(Chapter $chapter) + public function destroy(Chapter $chapter): void { $this->trashCan->softDestroyChapter($chapter); Activity::add(ActivityType::CHAPTER_DELETE, $chapter); @@ -93,7 +97,7 @@ public function move(Chapter $chapter, string $parentIdentifier): Book } return (new DatabaseTransaction(function () use ($chapter, $parent) { - $chapter->changeBook($parent->id); + $chapter = $chapter->changeBook($parent->id); $chapter->rebuildPermissions(); Activity::add(ActivityType::CHAPTER_MOVE, $chapter); diff --git a/app/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php index e47192cc2f1..5b67e5e6b52 100644 --- a/app/Entities/Repos/DeletionRepo.php +++ b/app/Entities/Repos/DeletionRepo.php @@ -9,11 +9,9 @@ class DeletionRepo { - private TrashCan $trashCan; - - public function __construct(TrashCan $trashCan) - { - $this->trashCan = $trashCan; + public function __construct( + protected TrashCan $trashCan + ) { } public function restore(int $id): int diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 76377f9a6dd..f2e558210ae 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -37,7 +37,7 @@ public function __construct( /** * Get a new draft page belonging to the given parent entity. */ - public function getNewDraftPage(Entity $parent) + public function getNewDraftPage(Entity $parent): Page { $page = (new Page())->forceFill([ 'name' => trans('entities.pages_initial_name'), @@ -46,6 +46,9 @@ public function getNewDraftPage(Entity $parent) 'updated_by' => user()->id, 'draft' => true, 'editor' => PageEditorType::getSystemDefault()->value, + 'html' => '', + 'markdown' => '', + 'text' => '', ]); if ($parent instanceof Chapter) { @@ -55,17 +58,18 @@ public function getNewDraftPage(Entity $parent) $page->book_id = $parent->id; } - $defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate; - if ($defaultTemplate && userCan(Permission::PageView, $defaultTemplate)) { + $defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get(); + if ($defaultTemplate) { $page->forceFill([ 'html' => $defaultTemplate->html, 'markdown' => $defaultTemplate->markdown, ]); + $page->text = (new PageContent($page))->toPlainText(); } (new DatabaseTransaction(function () use ($page) { $page->save(); - $page->refresh()->rebuildPermissions(); + $page->rebuildPermissions(); }))->run(); return $page; @@ -81,7 +85,8 @@ public function publishDraft(Page $draft, array $input): Page $draft->revision_count = 1; $draft->priority = $this->getNewPriority($draft); $this->updateTemplateStatusAndContentFromInput($draft, $input); - $this->baseRepo->update($draft, $input); + + $draft = $this->baseRepo->update($draft, $input); $draft->rebuildPermissions(); $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision'); @@ -112,12 +117,12 @@ public function setContentFromInput(Page $page, array $input): void public function update(Page $page, array $input): Page { // Hold the old details to compare later - $oldHtml = $page->html; $oldName = $page->name; + $oldHtml = $page->html; $oldMarkdown = $page->markdown; $this->updateTemplateStatusAndContentFromInput($page, $input); - $this->baseRepo->update($page, $input); + $page = $this->baseRepo->update($page, $input); // Update with new details $page->revision_count++; @@ -176,12 +181,12 @@ protected function updateTemplateStatusAndContentFromInput(Page $page, array $in /** * Save a page update draft. */ - public function updatePageDraft(Page $page, array $input) + public function updatePageDraft(Page $page, array $input): Page|PageRevision { - // If the page itself is a draft simply update that + // If the page itself is a draft, simply update that if ($page->draft) { $this->updateTemplateStatusAndContentFromInput($page, $input); - $page->fill($input); + $page->forceFill(array_intersect_key($input, array_flip(['name'])))->save(); $page->save(); return $page; @@ -209,7 +214,7 @@ public function updatePageDraft(Page $page, array $input) * * @throws Exception */ - public function destroy(Page $page) + public function destroy(Page $page): void { $this->trashCan->softDestroyPage($page); Activity::add(ActivityType::PAGE_DELETE, $page); @@ -279,7 +284,7 @@ public function move(Page $page, string $parentIdentifier): Entity return (new DatabaseTransaction(function () use ($page, $parent) { $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; - $page->changeBook($newBookId); + $page = $page->changeBook($newBookId); $page->rebuildPermissions(); Activity::add(ActivityType::PAGE_MOVE, $page); diff --git a/app/Entities/Repos/RevisionRepo.php b/app/Entities/Repos/RevisionRepo.php index d5549a0f14a..2d1371b63bd 100644 --- a/app/Entities/Repos/RevisionRepo.php +++ b/app/Entities/Repos/RevisionRepo.php @@ -23,7 +23,7 @@ public function deleteDraftsForCurrentUser(Page $page): void /** * Get a user update_draft page revision to update for the given page. - * Checks for an existing revisions before providing a fresh one. + * Checks for an existing revision before providing a fresh one. */ public function getNewDraftForCurrentUser(Page $page): PageRevision { @@ -72,7 +72,7 @@ public function storeNewForPage(Page $page, ?string $summary = null): PageRevisi /** * Delete old revisions, for the given page, from the system. */ - protected function deleteOldRevisions(Page $page) + protected function deleteOldRevisions(Page $page): void { $revisionLimit = config('app.revision_limit'); if ($revisionLimit === false) { diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 7dd3f3e11ad..4bbab626520 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -3,13 +3,10 @@ namespace BookStack\Entities\Tools; use BookStack\Entities\Models\Book; -use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; -use BookStack\Sorting\BookSortMap; -use BookStack\Sorting\BookSortMapItem; use Illuminate\Support\Collection; class BookContents @@ -29,7 +26,7 @@ public function getLastPriority(): int { $maxPage = $this->book->pages() ->where('draft', '=', false) - ->where('chapter_id', '=', 0) + ->whereDoesntHave('chapter') ->max('priority'); $maxChapter = $this->book->chapters() @@ -80,11 +77,11 @@ public function getTree(bool $showDrafts = false, bool $renderPages = false): Co protected function bookChildSortFunc(): callable { return function (Entity $entity) { - if (isset($entity['draft']) && $entity['draft']) { + if ($entity->getAttribute('draft') ?? false) { return -100; } - return $entity['priority'] ?? 0; + return $entity->getAttribute('priority') ?? 0; }; } diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php index 05618fef461..ff42ae6e41b 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -6,8 +6,8 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\HasCoverInterface; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\CoverImageInterface; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\ChapterRepo; @@ -106,8 +106,8 @@ public function entityToInputData(Entity $entity): array $inputData['tags'] = $this->entityTagsToInputArray($entity); // Add a cover to the data if existing on the original entity - if ($entity instanceof CoverImageInterface) { - $cover = $entity->cover()->first(); + if ($entity instanceof HasCoverInterface) { + $cover = $entity->coverInfo()->getImage(); if ($cover) { $inputData['image'] = $this->imageToUploadedFile($cover); } diff --git a/app/Entities/Tools/EntityCover.php b/app/Entities/Tools/EntityCover.php new file mode 100644 index 00000000000..1e8fce201dd --- /dev/null +++ b/app/Entities/Tools/EntityCover.php @@ -0,0 +1,75 @@ +where('id', '=', $this->entity->image_id); + } + + /** + * Check if a cover image exists for this entity. + */ + public function exists(): bool + { + return $this->entity->image_id !== null && $this->imageQuery()->exists(); + } + + /** + * Get the assigned cover image model. + */ + public function getImage(): Image|null + { + if ($this->entity->image_id === null) { + return null; + } + + $cover = $this->imageQuery()->first(); + if ($cover instanceof Image) { + return $cover; + } + + return null; + } + + /** + * Returns a cover image URL, or the given default if none assigned/existing. + */ + public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null + { + if (!$this->entity->image_id) { + return $default; + } + + try { + return $this->getImage()?->getThumb($width, $height, false) ?? $default; + } catch (Exception $err) { + return $default; + } + } + + /** + * Set the image to use as the cover for this entity. + */ + public function setImage(Image|null $image): void + { + if ($image === null) { + $this->entity->image_id = null; + } else { + $this->entity->image_id = $image->id; + } + } +} diff --git a/app/Entities/Tools/EntityDefaultTemplate.php b/app/Entities/Tools/EntityDefaultTemplate.php new file mode 100644 index 00000000000..d36c3f270e8 --- /dev/null +++ b/app/Entities/Tools/EntityDefaultTemplate.php @@ -0,0 +1,60 @@ +entity->default_template_id); + if (!$changing) { + return; + } + + if ($templateId === 0) { + $this->entity->default_template_id = null; + return; + } + + $pageQueries = app()->make(PageQueries::class); + $templateExists = $pageQueries->visibleTemplates() + ->where('id', '=', $templateId) + ->exists(); + + $this->entity->default_template_id = $templateExists ? $templateId : null; + } + + /** + * Get the default template for this entity (if visible). + */ + public function get(): Page|null + { + if (!$this->entity->default_template_id) { + return null; + } + + $pageQueries = app()->make(PageQueries::class); + $page = $pageQueries->visibleTemplates(true) + ->where('id', '=', $this->entity->default_template_id) + ->first(); + + if ($page instanceof Page) { + return $page; + } + + return null; + } +} diff --git a/app/Entities/Tools/EntityHtmlDescription.php b/app/Entities/Tools/EntityHtmlDescription.php new file mode 100644 index 00000000000..335703c36ad --- /dev/null +++ b/app/Entities/Tools/EntityHtmlDescription.php @@ -0,0 +1,60 @@ +html = $this->entity->description_html ?? ''; + $this->plain = $this->entity->description ?? ''; + } + + /** + * Update the description from HTML code. + * Optionally takes plaintext to use for the model also. + */ + public function set(string $html, string|null $plaintext = null): void + { + $this->html = $html; + $this->entity->description_html = $this->html; + + if ($plaintext !== null) { + $this->plain = $plaintext; + $this->entity->description = $this->plain; + } + + if (empty($html) && !empty($plaintext)) { + $this->html = $this->getHtml(); + $this->entity->description_html = $this->html; + } + } + + /** + * Get the description as HTML. + * Optionally returns the raw HTML if requested. + */ + public function getHtml(bool $raw = false): string + { + $html = $this->html ?: '

' . nl2br(e($this->plain)) . '

'; + if ($raw) { + return $html; + } + + return HtmlContentFilter::removeScriptsFromHtmlString($html); + } + + public function getPlain(): string + { + return $this->plain; + } +} diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php index b0d8880f402..fa45fcd116b 100644 --- a/app/Entities/Tools/HierarchyTransformer.php +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -34,6 +34,7 @@ public function transformChapterToBook(Chapter $chapter): Book /** @var Page $page */ foreach ($chapter->pages as $page) { $page->chapter_id = 0; + $page->save(); $page->changeBook($book->id); } diff --git a/app/Entities/Tools/MixedEntityListLoader.php b/app/Entities/Tools/MixedEntityListLoader.php index f9a940b981b..0a0f224d86c 100644 --- a/app/Entities/Tools/MixedEntityListLoader.php +++ b/app/Entities/Tools/MixedEntityListLoader.php @@ -19,7 +19,7 @@ public function __construct( * This will look for a model id and type via 'name_id' and 'name_type'. * @param Model[] $relations */ - public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void + public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void { $idsByType = []; foreach ($relations as $relation) { @@ -33,7 +33,7 @@ public function loadIntoRelations(array $relations, string $relationName, bool $ $idsByType[$type][] = $id; } - $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents); + $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents); foreach ($relations as $relation) { $type = $relation->getAttribute($relationName . '_type'); @@ -49,13 +49,13 @@ public function loadIntoRelations(array $relations, string $relationName, bool $ * @param array $idsByType * @return array> */ - protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array + protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array { $modelMap = []; foreach ($idsByType as $type => $ids) { - $models = $this->queries->visibleForList($type) - ->whereIn('id', $ids) + $base = $withContents ? $this->queries->visibleForContent($type) : $this->queries->visibleForList($type); + $models = $base->whereIn('id', $ids) ->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : []) ->get(); diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 4b1d77db720..c7a59216ad0 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -284,7 +284,7 @@ protected function setUniqueId(DOMNode $element, array &$idMap): array /** * Get a plain-text visualisation of this page. */ - protected function toPlainText(): string + public function toPlainText(): string { $html = $this->render(true); diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index d457d4f48db..cc43b909625 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -6,9 +6,10 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\EntityContainerData; +use BookStack\Entities\Models\HasCoverInterface; use BookStack\Entities\Models\Deletion; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\CoverImageInterface; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\NotifyException; @@ -140,6 +141,7 @@ protected function ensureDeletable(Entity $entity): void protected function destroyShelf(Bookshelf $shelf): int { $this->destroyCommonRelations($shelf); + $shelf->books()->detach(); $shelf->forceDelete(); return 1; @@ -167,6 +169,7 @@ protected function destroyBook(Book $book): int } $this->destroyCommonRelations($book); + $book->shelves()->detach(); $book->forceDelete(); return $count + 1; @@ -209,15 +212,19 @@ protected function destroyPage(Page $page): int $attachmentService->deleteFile($attachment); } - // Remove book template usages - $this->queries->books->start() + // Remove use as a template + EntityContainerData::query() ->where('default_template_id', '=', $page->id) ->update(['default_template_id' => null]); - // Remove chapter template usages - $this->queries->chapters->start() - ->where('default_template_id', '=', $page->id) - ->update(['default_template_id' => null]); + // TODO - Handle related images (uploaded_to for gallery/drawings). + // Should maybe reset to null + // But does that present visibility/permission issues if they used to retain their old + // unused ID? + // If so, might be better to leave them as-is like before, but ensure the maintenance + // cleanup command/action can find these "orphaned" images and delete them. + // But that would leave potential attachment to new pages on increment reset scenarios. + // Need to review permission scenarios for null field values relative to storage options. $page->forceDelete(); @@ -398,9 +405,11 @@ protected function destroyCommonRelations(Entity $entity) $entity->referencesTo()->delete(); $entity->referencesFrom()->delete(); - if ($entity instanceof CoverImageInterface && $entity->cover()->exists()) { + if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) { $imageService = app()->make(ImageService::class); - $imageService->destroy($entity->cover()->first()); + $imageService->destroy($entity->coverInfo()->getImage()); } + + $entity->relatedData()->delete(); } } diff --git a/app/Exports/ExportFormatter.php b/app/Exports/ExportFormatter.php index 85ac7d2c9e9..ad489aba1cb 100644 --- a/app/Exports/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -284,7 +284,7 @@ public function chapterToPlainText(Chapter $chapter): string public function bookToPlainText(Book $book): string { $bookTree = (new BookContents($book))->getTree(false, true); - $text = $book->name . "\n" . $book->description; + $text = $book->name . "\n" . $book->descriptionInfo()->getPlain(); $text = rtrim($text) . "\n\n"; $parts = []; @@ -318,7 +318,7 @@ public function chapterToMarkdown(Chapter $chapter): string { $text = '# ' . $chapter->name . "\n\n"; - $description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert(); + $description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->convert(); if ($description) { $text .= $description . "\n\n"; } @@ -338,7 +338,7 @@ public function bookToMarkdown(Book $book): string $bookTree = (new BookContents($book))->getTree(false, true); $text = '# ' . $book->name . "\n\n"; - $description = (new HtmlToMarkdown($book->descriptionHtml()))->convert(); + $description = (new HtmlToMarkdown($book->descriptionInfo()->getHtml()))->convert(); if ($description) { $text .= $description . "\n\n"; } diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 6c51ea3379c..ab3fd90ec1c 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -55,10 +55,10 @@ public static function fromModel(Book $model, ZipExportFiles $files): self $instance = new self(); $instance->id = $model->id; $instance->name = $model->name; - $instance->description_html = $model->descriptionHtml(); + $instance->description_html = $model->descriptionInfo()->getHtml(); - if ($model->cover) { - $instance->cover = $files->referenceForImage($model->cover); + if ($model->coverInfo()->exists()) { + $instance->cover = $files->referenceForImage($model->coverInfo()->getImage()); } $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index 260191a3e78..906ce3d8185 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -40,7 +40,7 @@ public static function fromModel(Chapter $model, ZipExportFiles $files): self $instance = new self(); $instance->id = $model->id; $instance->name = $model->name; - $instance->description_html = $model->descriptionHtml(); + $instance->description_html = $model->descriptionInfo()->getHtml(); $instance->priority = $model->priority; $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index eafb527e87e..748acf43f74 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -135,8 +135,8 @@ protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader 'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []), ]); - if ($book->cover) { - $this->references->addImage($book->cover, null); + if ($book->coverInfo()->getImage()) { + $this->references->addImage($book->coverInfo()->getImage(), null); } $children = [ @@ -197,8 +197,8 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z $this->pageRepo->publishDraft($page, [ 'name' => $exportPage->name, - 'markdown' => $exportPage->markdown, - 'html' => $exportPage->html, + 'markdown' => $exportPage->markdown ?? '', + 'html' => $exportPage->html ?? '', 'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []), ]); diff --git a/app/Permissions/PermissionApplicator.php b/app/Permissions/PermissionApplicator.php index 23fdcfda9e5..c44a18a4d53 100644 --- a/app/Permissions/PermissionApplicator.php +++ b/app/Permissions/PermissionApplicator.php @@ -40,10 +40,6 @@ public function checkOwnableUserAccess(Model&OwnableInterface $ownable, string|P $ownerField = $ownable->getOwnerFieldName(); $ownableFieldVal = $ownable->getAttribute($ownerField); - if (is_null($ownableFieldVal)) { - throw new InvalidArgumentException("{$ownerField} field used but has not been loaded"); - } - $isOwner = $user->id === $ownableFieldVal; $hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission); @@ -144,10 +140,10 @@ public function restrictEntityRelationQuery(Builder $query, string $tableName, s /** @var Builder $query */ $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass) ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) { - $query->select('id')->from('pages') - ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) + $query->select('page_id')->from('entity_page_data') + ->whereColumn('entity_page_data.page_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass) - ->where('pages.draft', '=', false); + ->where('entity_page_data.draft', '=', false); }); }); } @@ -197,18 +193,18 @@ public function restrictPageRelationQuery(Builder $query, string $tableName, str { $fullPageIdColumn = $tableName . '.' . $pageIdColumn; return $this->restrictEntityQuery($query) - ->where(function ($query) use ($fullPageIdColumn) { - /** @var Builder $query */ - $query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { - $query->select('id')->from('pages') - ->whereColumn('pages.id', '=', $fullPageIdColumn) - ->where('pages.draft', '=', false); - })->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { - $query->select('id')->from('pages') - ->whereColumn('pages.id', '=', $fullPageIdColumn) - ->where('pages.draft', '=', true) - ->where('pages.created_by', '=', $this->currentUser()->id); - }); + ->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { + $query->select('id')->from('entities') + ->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id') + ->whereColumn('entities.id', '=', $fullPageIdColumn) + ->where('entities.type', '=', 'page') + ->where(function (QueryBuilder $query) { + $query->where('entity_page_data.draft', '=', false) + ->orWhere(function (QueryBuilder $query) { + $query->where('entity_page_data.draft', '=', true) + ->where('entities.created_by', '=', $this->currentUser()->id); + }); + }); }); } diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php index 1c9664f45a9..8588c6e2c8e 100644 --- a/app/References/ReferenceFetcher.php +++ b/app/References/ReferenceFetcher.php @@ -20,10 +20,10 @@ public function __construct( * Query and return the references pointing to the given entity. * Loads the commonly required relations while taking permissions into account. */ - public function getReferencesToEntity(Entity $entity): Collection + public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection { $references = $this->queryReferencesToEntity($entity)->get(); - $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', true); + $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents); return $references; } diff --git a/app/References/ReferenceUpdater.php b/app/References/ReferenceUpdater.php index 5f1d711e9c6..06b3389bae5 100644 --- a/app/References/ReferenceUpdater.php +++ b/app/References/ReferenceUpdater.php @@ -3,9 +3,9 @@ namespace BookStack\References; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\HasDescriptionInterface; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\HtmlDescriptionInterface; -use BookStack\Entities\Models\HtmlDescriptionTrait; +use BookStack\Entities\Models\EntityContainerData; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\RevisionRepo; use BookStack\Util\HtmlDocument; @@ -36,7 +36,7 @@ public function updateEntityReferences(Entity $entity, string $oldLink): void protected function getReferencesToUpdate(Entity $entity): array { /** @var Reference[] $references */ - $references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all(); + $references = $this->referenceFetcher->getReferencesToEntity($entity, true)->values()->all(); if ($entity instanceof Book) { $pages = $entity->pages()->get(['id']); @@ -44,7 +44,7 @@ protected function getReferencesToUpdate(Entity $entity): array $children = $pages->concat($chapters); foreach ($children as $bookChild) { /** @var Reference[] $childRefs */ - $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all(); + $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild, true)->values()->all(); array_push($references, ...$childRefs); } } @@ -64,16 +64,16 @@ protected function updateReferencesWithinEntity(Entity $entity, string $oldLink, $this->updateReferencesWithinPage($entity, $oldLink, $newLink); } - if ($entity instanceof HtmlDescriptionInterface) { + if ($entity instanceof HasDescriptionInterface) { $this->updateReferencesWithinDescription($entity, $oldLink, $newLink); } } - protected function updateReferencesWithinDescription(Entity&HtmlDescriptionInterface $entity, string $oldLink, string $newLink): void + protected function updateReferencesWithinDescription(Entity&HasDescriptionInterface $entity, string $oldLink, string $newLink): void { - $entity = (clone $entity)->refresh(); - $html = $this->updateLinksInHtml($entity->descriptionHtml(true) ?: '', $oldLink, $newLink); - $entity->setDescriptionHtml($html); + $description = $entity->descriptionInfo(); + $html = $this->updateLinksInHtml($description->getHtml(true) ?: '', $oldLink, $newLink); + $description->set($html); $entity->save(); } diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index 1152101d293..99e307e35cc 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -33,22 +33,22 @@ public function runBookAutoSortForAllWithSet(SortRule $set): void */ public function runBookAutoSort(Book $book): void { - $set = $book->sortRule; - if (!$set) { + $rule = $book->sortRule()->first(); + if (!($rule instanceof SortRule)) { return; } $sortFunctions = array_map(function (SortRuleOperation $op) { return $op->getSortFunction(); - }, $set->getOperations()); + }, $rule->getOperations()); $chapters = $book->chapters() - ->with('pages:id,name,priority,created_at,updated_at,chapter_id') + ->with('pages:id,name,book_id,chapter_id,priority,created_at,updated_at') ->get(['id', 'name', 'priority', 'created_at', 'updated_at']); /** @var (Chapter|Book)[] $topItems */ $topItems = [ - ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']), + ...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']), ...$chapters, ]; @@ -155,11 +155,12 @@ protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMa // Action the required changes if ($bookChanged) { - $model->changeBook($newBook->id); + $model = $model->changeBook($newBook->id); } if ($model instanceof Page && $chapterChanged) { $model->chapter_id = $newChapter->id ?? 0; + $model->unsetRelation('chapter'); } if ($priorityChanged) { diff --git a/app/Sorting/SortRule.php b/app/Sorting/SortRule.php index 45e5514fd5f..bf53365a201 100644 --- a/app/Sorting/SortRule.php +++ b/app/Sorting/SortRule.php @@ -50,7 +50,7 @@ public function getUrl(): string public function books(): HasMany { - return $this->hasMany(Book::class); + return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id'); } public static function allByName(): Collection diff --git a/app/Sorting/SortRuleController.php b/app/Sorting/SortRuleController.php index bb5540a2a50..65e1cba098a 100644 --- a/app/Sorting/SortRuleController.php +++ b/app/Sorting/SortRuleController.php @@ -3,6 +3,7 @@ namespace BookStack\Sorting; use BookStack\Activity\ActivityType; +use BookStack\Entities\Models\EntityContainerData; use BookStack\Http\Controller; use BookStack\Permissions\Permission; use Illuminate\Http\Request; @@ -88,7 +89,9 @@ public function destroy(string $id, Request $request) if ($booksAssigned > 0) { if ($confirmed) { - $rule->books()->update(['sort_rule_id' => null]); + EntityContainerData::query() + ->where('sort_rule_id', $rule->id) + ->update(['sort_rule_id' => null]); } else { $warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]); } diff --git a/app/Uploads/Controllers/AttachmentApiController.php b/app/Uploads/Controllers/AttachmentApiController.php index b47d6ff8dd1..ea3c4a962b3 100644 --- a/app/Uploads/Controllers/AttachmentApiController.php +++ b/app/Uploads/Controllers/AttachmentApiController.php @@ -2,6 +2,7 @@ namespace BookStack\Uploads\Controllers; +use BookStack\Entities\EntityExistsRule; use BookStack\Entities\Queries\PageQueries; use BookStack\Exceptions\FileUploadException; use BookStack\Http\ApiController; @@ -173,13 +174,13 @@ protected function rules(): array return [ 'create' => [ 'name' => ['required', 'string', 'min:1', 'max:255'], - 'uploaded_to' => ['required', 'integer', 'exists:pages,id'], + 'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()), 'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'], ], 'update' => [ 'name' => ['string', 'min:1', 'max:255'], - 'uploaded_to' => ['integer', 'exists:pages,id'], + 'uploaded_to' => ['integer', new EntityExistsRule('page')], 'file' => $this->attachmentService->getFileValidationRules(), 'link' => ['string', 'min:1', 'max:2000', 'safe_url'], ], diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index 0886193e46d..9c60fa415f8 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -2,6 +2,7 @@ namespace BookStack\Uploads\Controllers; +use BookStack\Entities\EntityExistsRule; use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\PageRepo; use BookStack\Exceptions\FileUploadException; @@ -34,7 +35,7 @@ public function __construct( public function upload(Request $request) { $this->validate($request, [ - 'uploaded_to' => ['required', 'integer', 'exists:pages,id'], + 'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()), ]); @@ -144,7 +145,7 @@ public function attachLink(Request $request) try { $this->validate($request, [ - 'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'], + 'attachment_link_uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'], 'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'], ]); diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 458f0102d77..402456e9791 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -184,7 +184,7 @@ public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = t /** @var Image $image */ foreach ($images as $image) { $searchQuery = '%' . basename($image->path) . '%'; - $inPage = DB::table('pages') + $inPage = DB::table('entity_page_data') ->where('html', 'like', $searchQuery)->count() > 0; $inRevision = false; diff --git a/app/Users/Controllers/UserApiController.php b/app/Users/Controllers/UserApiController.php index 9134b3cc13b..25753280f17 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -2,6 +2,7 @@ namespace BookStack\Users\Controllers; +use BookStack\Entities\EntityExistsRule; use BookStack\Exceptions\UserUpdateException; use BookStack\Http\ApiController; use BookStack\Permissions\Permission; diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php index d24f7002e71..79d9e1b9eb2 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -6,12 +6,14 @@ use BookStack\Access\UserInviteService; use BookStack\Activity\ActivityType; use BookStack\Entities\EntityProvider; +use BookStack\Entities\Models\Entity; use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\UserUpdateException; use BookStack\Facades\Activity; use BookStack\Uploads\UserAvatars; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use DB; use Exception; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; @@ -181,6 +183,7 @@ public function destroy(User $user, ?int $newOwnerId = null) if (!is_null($newOwner)) { $this->migrateOwnership($user, $newOwner); } + // TODO - Should be be nullifying ownership instead? } Activity::add(ActivityType::USER_DELETE, $user); @@ -203,13 +206,11 @@ protected function ensureDeletable(User $user): void /** * Migrate ownership of items in the system from one user to another. */ - protected function migrateOwnership(User $fromUser, User $toUser) + protected function migrateOwnership(User $fromUser, User $toUser): void { - $entities = (new EntityProvider())->all(); - foreach ($entities as $instance) { - $instance->newQuery()->where('owned_by', '=', $fromUser->id) - ->update(['owned_by' => $toUser->id]); - } + DB::table('entities') + ->where('owned_by', '=', $fromUser->id) + ->update(['owned_by' => $toUser->id]); } /** diff --git a/database/factories/Entities/Models/ChapterFactory.php b/database/factories/Entities/Models/ChapterFactory.php index 1fc49933ef7..abf554ac894 100644 --- a/database/factories/Entities/Models/ChapterFactory.php +++ b/database/factories/Entities/Models/ChapterFactory.php @@ -26,7 +26,8 @@ public function definition() 'name' => $this->faker->sentence(), 'slug' => Str::random(10), 'description' => $description, - 'description_html' => '

' . e($description) . '

' + 'description_html' => '

' . e($description) . '

', + 'priority' => 5, ]; } } diff --git a/database/factories/Entities/Models/PageFactory.php b/database/factories/Entities/Models/PageFactory.php index 8115700950c..47e5aa5db2b 100644 --- a/database/factories/Entities/Models/PageFactory.php +++ b/database/factories/Entities/Models/PageFactory.php @@ -31,6 +31,7 @@ public function definition() 'text' => strip_tags($html), 'revision_count' => 1, 'editor' => 'wysiwyg', + 'priority' => 1, ]; } } diff --git a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php index 0e25c1d6052..a8f1843ed92 100644 --- a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php +++ b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php @@ -25,9 +25,6 @@ public function up(): void $table->unsignedInteger('owner_id')->nullable()->index(); }); } - - // Rebuild permissions - app(JointPermissionBuilder::class)->rebuildForAll(); } /** diff --git a/database/migrations/2025_09_15_132850_create_entities_table.php b/database/migrations/2025_09_15_132850_create_entities_table.php new file mode 100644 index 00000000000..6c890d7191d --- /dev/null +++ b/database/migrations/2025_09_15_132850_create_entities_table.php @@ -0,0 +1,71 @@ +bigIncrements('id'); + $table->string('type', 10)->index(); + $table->string('name'); + $table->string('slug')->index(); + + $table->unsignedBigInteger('book_id')->nullable()->index(); + $table->unsignedBigInteger('chapter_id')->nullable()->index(); + $table->unsignedInteger('priority')->nullable(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable()->index(); + $table->timestamp('deleted_at')->nullable()->index(); + + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->unsignedInteger('owned_by')->nullable()->index(); + + $table->primary(['id', 'type'], 'entities_pk'); + }); + + Schema::create('entity_container_data', function (Blueprint $table) { + $table->unsignedBigInteger('entity_id'); + $table->string('entity_type', 10); + $table->text('description'); + $table->text('description_html'); + + $table->unsignedBigInteger('default_template_id')->nullable(); + $table->unsignedInteger('image_id')->nullable(); + $table->unsignedInteger('sort_rule_id')->nullable(); + + $table->primary(['entity_id', 'entity_type'], 'entity_container_data_pk'); + }); + + Schema::create('entity_page_data', function (Blueprint $table) { + $table->unsignedBigInteger('page_id')->primary(); + + $table->boolean('draft')->index(); + $table->boolean('template')->index(); + $table->unsignedInteger('revision_count'); + $table->string('editor', 50); + + $table->longText('html'); + $table->longText('text'); + $table->longText('markdown'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entities'); + Schema::dropIfExists('entity_container_data'); + Schema::dropIfExists('entity_page_data'); + } +}; diff --git a/database/migrations/2025_09_15_134701_migrate_entity_data.php b/database/migrations/2025_09_15_134701_migrate_entity_data.php new file mode 100644 index 00000000000..7b4beef0673 --- /dev/null +++ b/database/migrations/2025_09_15_134701_migrate_entity_data.php @@ -0,0 +1,90 @@ + 'book', 'bookshelves' => 'bookshelf'] as $table => $type) { + DB::table('entities')->insertUsing([ + 'id', 'type', 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ], DB::table($table)->select([ + 'id', DB::raw("'{$type}'"), 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ])); + } + + // Migrate chapter data to entities + DB::table('entities')->insertUsing([ + 'id', 'type', 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ], DB::table('chapters')->select([ + 'id', DB::raw("'chapter'"), 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ])); + + DB::table('entities')->insertUsing([ + 'id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ], DB::table('pages')->select([ + 'id', DB::raw("'page'"), 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ])); + + // Migrate shelf data to entity_container_data + DB::table('entity_container_data')->insertUsing([ + 'entity_id', 'entity_type', 'description', 'description_html', 'image_id', + ], DB::table('bookshelves')->select([ + 'id', DB::raw("'bookshelf'"), 'description', 'description_html', 'image_id', + ])); + + // Migrate book data to entity_container_data + DB::table('entity_container_data')->insertUsing([ + 'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id' + ], DB::table('books')->select([ + 'id', DB::raw("'book'"), 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id' + ])); + + // Migrate chapter data to entity_container_data + DB::table('entity_container_data')->insertUsing([ + 'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id', + ], DB::table('chapters')->select([ + 'id', DB::raw("'chapter'"), 'description', 'description_html', 'default_template_id', + ])); + + // Migrate page data to entity_page_data + DB::table('entity_page_data')->insertUsing([ + 'page_id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown', + ], DB::table('pages')->select([ + 'id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown', + ])); + + // Fix up data - Convert 0 id references to null + DB::table('entities')->where('created_by', '=', 0)->update(['created_by' => null]); + DB::table('entities')->where('updated_by', '=', 0)->update(['updated_by' => null]); + DB::table('entities')->where('owned_by', '=', 0)->update(['owned_by' => null]); + DB::table('entities')->where('chapter_id', '=', 0)->update(['chapter_id' => null]); + + // Fix up data - Convert any missing id-based references to null + $userIdQuery = DB::table('users')->select('id'); + DB::table('entities')->whereNotIn('created_by', $userIdQuery)->update(['created_by' => null]); + DB::table('entities')->whereNotIn('updated_by', $userIdQuery)->update(['updated_by' => null]); + DB::table('entities')->whereNotIn('owned_by', $userIdQuery)->update(['owned_by' => null]); + DB::table('entities')->whereNotIn('chapter_id', DB::table('chapters')->select('id'))->update(['chapter_id' => null]); + + // Commit our changes within our transaction + DB::commit(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // No action here since the actual data remains in the database for the old tables, + // so data reversion actions are done in a later migration when the old tables are dropped. + } +}; diff --git a/database/migrations/2025_09_15_134751_update_entity_relation_columns.php b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php new file mode 100644 index 00000000000..267cd49f551 --- /dev/null +++ b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php @@ -0,0 +1,114 @@ +> $columnByTable + */ + protected static array $columnByTable = [ + 'activities' => 'loggable_id', + 'attachments' => 'uploaded_to', + 'bookshelves_books' => ['bookshelf_id', 'book_id'], + 'comments' => 'entity_id', + 'deletions' => 'deletable_id', + 'entity_permissions' => 'entity_id', + 'favourites' => 'favouritable_id', + 'images' => 'uploaded_to', + 'joint_permissions' => 'entity_id', + 'page_revisions' => 'page_id', + 'references' => ['from_id', 'to_id'], + 'search_terms' => 'entity_id', + 'tags' => 'entity_id', + 'views' => 'viewable_id', + 'watches' => 'watchable_id', + ]; + + protected static array $nullable = [ + 'activities.loggable_id', + 'images.uploaded_to', + ]; + + /** + * Run the migrations. + */ + public function up(): void + { + // Drop foreign key constraints + Schema::table('bookshelves_books', function (Blueprint $table) { + $table->dropForeign(['book_id']); + $table->dropForeign(['bookshelf_id']); + }); + + // Update column types to unsigned big integers + foreach (static::$columnByTable as $table => $column) { + $tableName = $table; + Schema::table($table, function (Blueprint $table) use ($tableName, $column) { + if (is_string($column)) { + $column = [$column]; + } + + foreach ($column as $col) { + if (in_array($tableName . '.' . $col, static::$nullable)) { + $table->unsignedBigInteger($col)->nullable()->change(); + } else { + $table->unsignedBigInteger($col)->change(); + } + } + }); + } + + // Convert image zero values to null + DB::table('images')->where('uploaded_to', '=', 0)->update(['uploaded_to' => null]); + + // Rebuild joint permissions if needed + // This was moved here from 2023_01_24_104625_refactor_joint_permissions_storage since the changes + // made for this release would mean our current logic would not be compatible with + // the database changes being made. This is based on a count since any joint permissions + // would have been truncated in the previous migration. + if (\Illuminate\Support\Facades\DB::table('joint_permissions')->count() === 0) { + app(JointPermissionBuilder::class)->rebuildForAll(); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Convert image null values back to zeros + DB::table('images')->whereNull('uploaded_to')->update(['uploaded_to' => '0']); + + // Revert columns to standard integers + foreach (static::$columnByTable as $table => $column) { + $tableName = $table; + Schema::table($table, function (Blueprint $table) use ($tableName, $column) { + if (is_string($column)) { + $column = [$column]; + } + + foreach ($column as $col) { + if ($tableName . '.' . $col === 'activities.loggable_id') { + $table->unsignedInteger($col)->nullable()->change(); + } else if ($tableName . '.' . $col === 'images.uploaded_to') { + $table->unsignedInteger($col)->default(0)->change(); + } else { + $table->unsignedInteger($col)->change(); + } + } + }); + } + + // Re-add foreign key constraints + Schema::table('bookshelves_books', function (Blueprint $table) { + $table->foreign('bookshelf_id')->references('id')->on('bookshelves') + ->onUpdate('cascade')->onDelete('cascade'); + $table->foreign('book_id')->references('id')->on('books') + ->onUpdate('cascade')->onDelete('cascade'); + }); + } +}; diff --git a/database/migrations/2025_09_15_134813_drop_old_entity_tables.php b/database/migrations/2025_09_15_134813_drop_old_entity_tables.php new file mode 100644 index 00000000000..d6360f74db1 --- /dev/null +++ b/database/migrations/2025_09_15_134813_drop_old_entity_tables.php @@ -0,0 +1,162 @@ +unsignedInteger('id', true)->primary(); + $table->integer('book_id')->index(); + $table->integer('chapter_id')->index(); + $table->string('name'); + $table->string('slug')->index(); + $table->longText('html'); + $table->longText('text'); + $table->integer('priority')->index(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable()->index(); + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + + $table->boolean('draft')->default(0)->index(); + $table->longText('markdown'); + $table->integer('revision_count'); + $table->boolean('template')->default(0)->index(); + $table->timestamp('deleted_at')->nullable(); + + $table->unsignedInteger('owned_by')->index(); + $table->string('editor', 50)->default(''); + }); + + Schema::create('chapters', function (Blueprint $table) { + $table->unsignedInteger('id', true)->primary(); + $table->integer('book_id')->index(); + $table->string('slug')->index(); + $table->text('name'); + $table->text('description'); + $table->integer('priority')->index(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + + $table->timestamp('deleted_at')->nullable(); + $table->unsignedInteger('owned_by')->index(); + $table->text('description_html'); + $table->integer('default_template_id')->nullable(); + }); + + Schema::create('books', function (Blueprint $table) { + $table->unsignedInteger('id', true)->primary(); + $table->string('name'); + $table->string('slug')->index(); + $table->text('description'); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + + $table->integer('image_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->unsignedInteger('owned_by')->index(); + + $table->integer('default_template_id')->nullable(); + $table->text('description_html'); + $table->unsignedInteger('sort_rule_id')->nullable(); + }); + + Schema::create('bookshelves', function (Blueprint $table) { + $table->unsignedInteger('id', true)->primary(); + $table->string('name', 180); + $table->string('slug', 180)->index(); + $table->text('description'); + + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + $table->integer('image_id')->nullable(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + + $table->unsignedInteger('owned_by')->index(); + $table->text('description_html'); + }); + + DB::beginTransaction(); + + // Revert nulls back to zeros + DB::table('entities')->whereNull('created_by')->update(['created_by' => 0]); + DB::table('entities')->whereNull('updated_by')->update(['updated_by' => 0]); + DB::table('entities')->whereNull('owned_by')->update(['owned_by' => 0]); + DB::table('entities')->whereNull('chapter_id')->update(['chapter_id' => 0]); + + // Restore data back into pages table + $pageFields = [ + 'id', 'book_id', 'chapter_id', 'name', 'slug', 'html', 'text', 'priority', 'created_at', 'updated_at', + 'created_by', 'updated_by', 'draft', 'markdown', 'revision_count', 'template', 'deleted_at', 'owned_by', 'editor' + ]; + $pageQuery = DB::table('entities')->select($pageFields) + ->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id') + ->where('type', '=', 'page'); + DB::table('pages')->insertUsing($pageFields, $pageQuery); + + // Restore data back into chapters table + $containerJoinClause = function (JoinClause $join) { + return $join->on('entities.id', '=', 'entity_container_data.entity_id') + ->on('entities.type', '=', 'entity_container_data.entity_type'); + }; + $chapterFields = [ + 'id', 'book_id', 'slug', 'name', 'description', 'priority', 'created_at', 'updated_at', 'created_by', 'updated_by', + 'deleted_at', 'owned_by', 'description_html', 'default_template_id' + ]; + $chapterQuery = DB::table('entities')->select($chapterFields) + ->leftJoin('entity_container_data', $containerJoinClause) + ->where('type', '=', 'chapter'); + DB::table('chapters')->insertUsing($chapterFields, $chapterQuery); + + // Restore data back into books table + $bookFields = [ + 'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'image_id', + 'deleted_at', 'owned_by', 'default_template_id', 'description_html', 'sort_rule_id' + ]; + $bookQuery = DB::table('entities')->select($bookFields) + ->leftJoin('entity_container_data', $containerJoinClause) + ->where('type', '=', 'book'); + DB::table('books')->insertUsing($bookFields, $bookQuery); + + // Restore data back into bookshelves table + $shelfFields = [ + 'id', 'name', 'slug', 'description', 'created_by', 'updated_by', 'image_id', 'created_at', 'updated_at', + 'deleted_at', 'owned_by', 'description_html', + ]; + $shelfQuery = DB::table('entities')->select($shelfFields) + ->leftJoin('entity_container_data', $containerJoinClause) + ->where('type', '=', 'bookshelf'); + DB::table('bookshelves')->insertUsing($shelfFields, $shelfQuery); + + DB::commit(); + } +}; diff --git a/database/seeders/DummyContentSeeder.php b/database/seeders/DummyContentSeeder.php index a4383be50a2..5f787259a18 100644 --- a/database/seeders/DummyContentSeeder.php +++ b/database/seeders/DummyContentSeeder.php @@ -12,7 +12,10 @@ use BookStack\Search\SearchIndex; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Seeder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -39,40 +42,58 @@ public function run() $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id]; - Book::factory()->count(5)->create($byData) + Book::factory()->count(5)->make($byData) ->each(function ($book) use ($byData) { + $book->save(); $chapters = Chapter::factory()->count(3)->create($byData) ->each(function ($chapter) use ($book, $byData) { $pages = Page::factory()->count(3)->make(array_merge($byData, ['book_id' => $book->id])); - $chapter->pages()->saveMany($pages); + $this->saveManyOnRelation($pages, $chapter->pages()); }); $pages = Page::factory()->count(3)->make($byData); - $book->chapters()->saveMany($chapters); - $book->pages()->saveMany($pages); + $this->saveManyOnRelation($chapters, $book->chapters()); + $this->saveManyOnRelation($pages, $book->pages()); }); - $largeBook = Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)])); + $largeBook = Book::factory()->make(array_merge($byData, ['name' => 'Large book' . Str::random(10)])); + $largeBook->save(); + $pages = Page::factory()->count(200)->make($byData); $chapters = Chapter::factory()->count(50)->make($byData); - $largeBook->pages()->saveMany($pages); - $largeBook->chapters()->saveMany($chapters); + $this->saveManyOnRelation($pages, $largeBook->pages()); + $this->saveManyOnRelation($chapters, $largeBook->chapters()); + + $shelves = Bookshelf::factory()->count(10)->make($byData); + foreach ($shelves as $shelf) { + $shelf->save(); + } - $shelves = Bookshelf::factory()->count(10)->create($byData); $largeBook->shelves()->attach($shelves->pluck('id')); // Assign API permission to editor role and create an API key $apiPermission = RolePermission::getByName('access-api'); $editorRole->attachPermission($apiPermission); $token = (new ApiToken())->forceFill([ - 'user_id' => $editorUser->id, - 'name' => 'Testing API key', + 'user_id' => $editorUser->id, + 'name' => 'Testing API key', 'expires_at' => ApiToken::defaultExpiry(), - 'secret' => Hash::make('password'), - 'token_id' => 'apitoken', + 'secret' => Hash::make('password'), + 'token_id' => 'apitoken', ]); $token->save(); app(JointPermissionBuilder::class)->rebuildForAll(); app(SearchIndex::class)->indexAllEntities(); } + + /** + * Inefficient workaround for saving many on a relation since we can't directly insert + * entities since we split them across tables. + */ + protected function saveManyOnRelation(Collection $entities, HasMany $relation): void + { + foreach ($entities as $entity) { + $relation->save($entity); + } + } } diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json index afeebade619..582744f99a5 100644 --- a/dev/api/responses/books-read.json +++ b/dev/api/responses/books-read.json @@ -52,7 +52,7 @@ "name": "Cool Animals", "slug": "cool-animals", "book_id": 16, - "chapter_id": 0, + "chapter_id": null, "draft": false, "template": false, "created_at": "2021-12-19T18:22:11.000000Z", diff --git a/dev/api/responses/pages-create.json b/dev/api/responses/pages-create.json index 11f5ab8c8a5..705dea6f619 100644 --- a/dev/api/responses/pages-create.json +++ b/dev/api/responses/pages-create.json @@ -1,7 +1,7 @@ { "id": 358, "book_id": 1, - "chapter_id": 0, + "chapter_id": null, "name": "My API Page", "slug": "my-api-page", "html": "

my new API page

", diff --git a/dev/api/responses/pages-read.json b/dev/api/responses/pages-read.json index 2f3538964d4..22ff2de84af 100644 --- a/dev/api/responses/pages-read.json +++ b/dev/api/responses/pages-read.json @@ -1,7 +1,7 @@ { "id": 306, "book_id": 1, - "chapter_id": 0, + "chapter_id": null, "name": "A page written in markdown", "slug": "a-page-written-in-markdown", "html": "

This is my cool page! With some included text

", diff --git a/dev/api/responses/recycle-bin-list.json b/dev/api/responses/recycle-bin-list.json index 853070839e9..19167ec05fa 100644 --- a/dev/api/responses/recycle-bin-list.json +++ b/dev/api/responses/recycle-bin-list.json @@ -10,7 +10,7 @@ "deletable": { "id": 2582, "book_id": 25, - "chapter_id": 0, + "chapter_id": null, "name": "A Wonderful Page", "slug": "a-wonderful-page", "priority": 9, diff --git a/resources/views/books/parts/form.blade.php b/resources/views/books/parts/form.blade.php index 44d495c2777..bb2cc936fe0 100644 --- a/resources/views/books/parts/form.blade.php +++ b/resources/views/books/parts/form.blade.php @@ -18,7 +18,7 @@ @include('form.image-picker', [ 'defaultImage' => url('/book_default_cover.png'), - 'currentImage' => (isset($model) && $model->cover) ? $model->getBookCover() : url('/book_default_cover.png') , + 'currentImage' => (($model ?? null)?->coverInfo()->getUrl(440, 250, null) ?? url('/book_default_cover.png')), 'name' => 'image', 'imageClass' => 'cover' ]) diff --git a/resources/views/books/parts/list-item.blade.php b/resources/views/books/parts/list-item.blade.php index a3ff0971f11..0852670fe6d 100644 --- a/resources/views/books/parts/list-item.blade.php +++ b/resources/views/books/parts/list-item.blade.php @@ -1,11 +1,16 @@ +@php + /** + * @var \BookStack\Entities\Models\Book $book + */ +@endphp -
+
@icon('book')

{{ $book->name }}

-

{{ $book->description }}

+

{{ $book->descriptionInfo()->getPlain() }}

\ No newline at end of file diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index e28d9564829..d510c8fd560 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -8,8 +8,8 @@ @push('social-meta') - @if($book->cover) - + @if($book->coverInfo()->exists()) + @endif @endpush @@ -26,7 +26,7 @@

{{$book->name}}

-
{!! $book->descriptionHtml() !!}
+
{!! $book->descriptionInfo()->getHtml() !!}
@if(count($bookChildren) > 0)
@foreach($bookChildren as $childElement) diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index da914b32d21..585bf8a3b8d 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -24,7 +24,7 @@

{{ $chapter->name }}

-
{!! $chapter->descriptionHtml() !!}
+
{!! $chapter->descriptionInfo()->getHtml() !!}
@if(count($pages) > 0)
@foreach($pages as $page) diff --git a/resources/views/entities/grid-item.blade.php b/resources/views/entities/grid-item.blade.php index 17c54e26353..e64d0f42cd8 100644 --- a/resources/views/entities/grid-item.blade.php +++ b/resources/views/entities/grid-item.blade.php @@ -1,7 +1,7 @@
@@ -42,7 +45,8 @@ class="scroll-box configured-option-list">
- +
    @@ -54,7 +58,6 @@ class="scroll-box available-option-list">
-
- {{ trans('common.cancel') }} + {{ trans('common.cancel') }}
diff --git a/resources/views/shelves/parts/list-item.blade.php b/resources/views/shelves/parts/list-item.blade.php index 00cacfa707c..5fc8a362b32 100644 --- a/resources/views/shelves/parts/list-item.blade.php +++ b/resources/views/shelves/parts/list-item.blade.php @@ -1,5 +1,5 @@ -
+
@icon('bookshelf')
diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index 633f959f3c9..9ee14f1bf4e 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -2,8 +2,8 @@ @push('social-meta') - @if($shelf->cover) - + @if($shelf->coverInfo()->exists()) + @endif @endpush @@ -28,7 +28,7 @@
-
{!! $shelf->descriptionHtml() !!}
+
{!! $shelf->descriptionInfo()->getHtml() !!}
@if(count($sortedVisibleShelfBooks) > 0) @if($view === 'list')
diff --git a/tests/Api/ApiAuthTest.php b/tests/Api/ApiAuthTest.php index 93e4b02e423..4e446bf5d1a 100644 --- a/tests/Api/ApiAuthTest.php +++ b/tests/Api/ApiAuthTest.php @@ -12,7 +12,7 @@ class ApiAuthTest extends TestCase { use TestsApi; - protected $endpoint = '/api/books'; + protected string $endpoint = '/api/books'; public function test_requests_succeed_with_default_auth() { diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 22ccfb482c9..e5bd77b67d1 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -47,8 +47,8 @@ public function test_index_endpoint_includes_cover_if_set() [ 'id' => $book->id, 'cover' => [ - 'id' => $book->cover->id, - 'url' => $book->cover->url, + 'id' => $book->coverInfo()->getImage()->id, + 'url' => $book->coverInfo()->getImage()->url, ], ], ]]); @@ -94,7 +94,7 @@ public function test_create_endpoint_with_html() ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('books', $expectedDetails); + $this->assertDatabaseHasEntityData('book', $expectedDetails); } public function test_book_name_needed_to_create() @@ -153,23 +153,23 @@ public function test_read_endpoint_includes_chapter_and_page_contents() $directChildCount = $book->directPages()->count() + $book->chapters()->count(); $resp->assertStatus(200); $resp->assertJsonCount($directChildCount, 'contents'); - $resp->assertJson([ - 'contents' => [ - [ - 'type' => 'chapter', - 'id' => $chapter->id, - 'name' => $chapter->name, - 'slug' => $chapter->slug, - 'pages' => [ - [ - 'id' => $chapterPage->id, - 'name' => $chapterPage->name, - 'slug' => $chapterPage->slug, - ] - ] - ] - ] - ]); + + $contents = $resp->json('contents'); + $respChapter = array_values(array_filter($contents, fn ($item) => ($item['id'] === $chapter->id && $item['type'] === 'chapter')))[0]; + $this->assertArrayMapIncludes([ + 'id' => $chapter->id, + 'type' => 'chapter', + 'name' => $chapter->name, + 'slug' => $chapter->slug, + ], $respChapter); + + $respPage = array_values(array_filter($respChapter['pages'], fn ($item) => ($item['id'] === $chapterPage->id)))[0]; + + $this->assertArrayMapIncludes([ + 'id' => $chapterPage->id, + 'name' => $chapterPage->name, + 'slug' => $chapterPage->slug, + ], $respPage); } public function test_read_endpoint_contents_nested_pages_has_permissions_applied() @@ -224,14 +224,14 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('books', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API'])); + $this->assertDatabaseHasEntityData('book', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API'])); } public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $book = $this->entities->book(); - DB::table('books')->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]); + Book::query()->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -247,7 +247,7 @@ public function test_update_cover_image_control() $this->actingAsApiEditor(); /** @var Book $book */ $book = $this->entities->book(); - $this->assertNull($book->cover); + $this->assertNull($book->coverInfo()->getImage()); $file = $this->files->uploadedImage('image.png'); // Ensure cover image can be set via API @@ -257,7 +257,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNotNull($book->cover); + $this->assertNotNull($book->coverInfo()->getImage()); // Ensure further updates without image do not clear cover image $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ @@ -266,7 +266,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNotNull($book->cover); + $this->assertNotNull($book->coverInfo()->getImage()); // Ensure update with null image property clears image $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ @@ -275,7 +275,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNull($book->cover); + $this->assertNull($book->coverInfo()->getImage()); } public function test_delete_endpoint() diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php index 5d7b0530891..194140a569a 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -91,7 +91,7 @@ public function test_create_endpoint_with_html() 'description' => 'A chapter created via the API', ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('chapters', $expectedDetails); + $this->assertDatabaseHasEntityData('chapter', $expectedDetails); } public function test_chapter_name_needed_to_create() @@ -155,7 +155,7 @@ public function test_read_endpoint() 'owned_by' => $page->owned_by, 'created_by' => $page->created_by, 'updated_by' => $page->updated_by, - 'book_id' => $page->id, + 'book_id' => $page->book->id, 'chapter_id' => $chapter->id, 'priority' => $page->priority, 'book_slug' => $chapter->book->slug, @@ -213,7 +213,7 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('chapters', array_merge($details, [ + $this->assertDatabaseHasEntityData('chapter', array_merge($details, [ 'id' => $chapter->id, 'description' => 'A chapter updated via the API' ])); } @@ -222,7 +222,7 @@ public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $chapter = $this->entities->chapter(); - DB::table('chapters')->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $chapter->newQuery()->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -244,8 +244,8 @@ public function test_update_with_book_id_moves_chapter() $resp->assertOk(); $chapter->refresh(); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'book_id' => $newBook->id]); - $this->assertDatabaseHas('pages', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'book_id' => $newBook->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]); } public function test_update_with_new_book_id_requires_delete_permission() diff --git a/tests/Api/ContentPermissionsApiTest.php b/tests/Api/ContentPermissionsApiTest.php index a62abacc75e..464d62683ad 100644 --- a/tests/Api/ContentPermissionsApiTest.php +++ b/tests/Api/ContentPermissionsApiTest.php @@ -280,7 +280,7 @@ public function test_update_can_both_provide_owner_and_fallback_permissions() ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', ['id' => $page->id, 'owned_by' => $user->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'owned_by' => $user->id]); $this->assertDatabaseHas('entity_permissions', [ 'entity_id' => $page->id, 'entity_type' => 'page', diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index ced8954eb11..8caf85affbe 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -286,7 +286,7 @@ public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $page = $this->entities->page(); - DB::table('pages')->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $page->newQuery()->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php index d174838c27d..6ccc69c3545 100644 --- a/tests/Api/RecycleBinApiTest.php +++ b/tests/Api/RecycleBinApiTest.php @@ -144,7 +144,7 @@ public function test_restore_endpoint() $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -154,7 +154,7 @@ public function test_restore_endpoint() 'restore_count' => 1, ]); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => null, ]); @@ -168,7 +168,7 @@ public function test_destroy_endpoint() $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -178,6 +178,6 @@ public function test_destroy_endpoint() 'delete_count' => 1, ]); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); } } diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php index ba13c0153b1..34ce0e4e5b2 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -48,8 +48,8 @@ public function test_index_endpoint_includes_cover_if_set() [ 'id' => $shelf->id, 'cover' => [ - 'id' => $shelf->cover->id, - 'url' => $shelf->cover->url, + 'id' => $shelf->coverInfo()->getImage()->id, + 'url' => $shelf->coverInfo()->getImage()->url, ], ], ]]); @@ -102,7 +102,7 @@ public function test_create_endpoint_with_html() ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('bookshelves', $expectedDetails); + $this->assertDatabaseHasEntityData('bookshelf', $expectedDetails); } public function test_shelf_name_needed_to_create() @@ -181,14 +181,14 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('bookshelves', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API'])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API'])); } public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $shelf = Bookshelf::visible()->first(); - DB::table('bookshelves')->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $shelf->newQuery()->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -222,7 +222,7 @@ public function test_update_cover_image_control() $this->actingAsApiEditor(); /** @var Book $shelf */ $shelf = Bookshelf::visible()->first(); - $this->assertNull($shelf->cover); + $this->assertNull($shelf->coverInfo()->getImage()); $file = $this->files->uploadedImage('image.png'); // Ensure cover image can be set via API @@ -232,7 +232,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNotNull($shelf->cover); + $this->assertNotNull($shelf->coverInfo()->getImage()); // Ensure further updates without image do not clear cover image $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ @@ -241,7 +241,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNotNull($shelf->cover); + $this->assertNotNull($shelf->coverInfo()->getImage()); // Ensure update with null image property clears image $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ @@ -250,7 +250,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNull($shelf->cover); + $this->assertNull($shelf->coverInfo()->getImage()); } public function test_delete_endpoint() diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php index 1f359b41a10..5184bf9843c 100644 --- a/tests/Auth/MfaConfigurationTest.php +++ b/tests/Auth/MfaConfigurationTest.php @@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Support\Facades\Hash; use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; @@ -166,6 +167,36 @@ public function test_remove_mfa_method() $this->assertEquals(0, $admin->mfaValues()->count()); } + public function test_mfa_required_if_set_on_role() + { + $user = $this->users->viewer(); + $user->password = Hash::make('password'); + $user->save(); + /** @var Role $role */ + $role = $user->roles()->first(); + $role->mfa_enforced = true; + $role->save(); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->assertFalse(auth()->check()); + $resp->assertRedirect('/mfa/verify'); + } + + public function test_mfa_required_if_mfa_option_configured() + { + $user = $this->users->viewer(); + $user->password = Hash::make('password'); + $user->save(); + $user->mfaValues()->create([ + 'method' => MfaValue::METHOD_TOTP, + 'value' => 'test', + ]); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->assertFalse(auth()->check()); + $resp->assertRedirect('/mfa/verify'); + } + public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login() { $admin = $this->users->admin(); diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php index d336e05a240..356a026a849 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -19,7 +19,7 @@ public function test_command_updates_page_content() ->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y') ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y'); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'html' => '', ]); @@ -40,7 +40,7 @@ public function test_command_updates_description_html() ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y'); foreach ($models as $model) { - $this->assertDatabaseHas($model->getTable(), [ + $this->assertDatabaseHasEntityData($model->getMorphClass(), [ 'id' => $model->id, 'description_html' => '', ]); diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index ad1d64e7126..3ba2c3e99c8 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -91,7 +91,7 @@ public function test_shelves_create() ])); $resp->assertRedirect(); $editorId = $this->users->editor()->id; - $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first(); $shelfPage = $this->get($shelf->getUrl()); @@ -117,11 +117,12 @@ public function test_shelves_create_sets_cover_image() $lastImage = Image::query()->orderByDesc('id')->firstOrFail(); $shelf = Bookshelf::query()->where('name', '=', $shelfInfo['name'])->first(); - $this->assertDatabaseHas('bookshelves', [ - 'id' => $shelf->id, + $this->assertDatabaseHas('entity_container_data', [ + 'entity_id' => $shelf->id, + 'entity_type' => 'bookshelf', 'image_id' => $lastImage->id, ]); - $this->assertEquals($lastImage->id, $shelf->cover->id); + $this->assertEquals($lastImage->id, $shelf->coverInfo()->getImage()->id); $this->assertEquals('cover_bookshelf', $lastImage->type); } @@ -247,7 +248,7 @@ public function test_shelf_edit() $this->assertSessionHas('success'); $editorId = $this->users->editor()->id; - $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); $shelfPage = $this->get($shelf->getUrl()); $shelfPage->assertSee($shelfInfo['name']); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 51bf65d10bb..543c4e8bbdb 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -27,7 +27,7 @@ public function test_create() $resp = $this->get('/books/my-first-book'); $resp->assertSee($book->name); - $resp->assertSee($book->description); + $resp->assertSee($book->descriptionInfo()->getPlain()); } public function test_create_uses_different_slugs_when_name_reused() @@ -362,12 +362,12 @@ public function test_copy_clones_cover_image_if_existing() $coverImageFile = $this->files->uploadedImage('cover.png'); $bookRepo->updateCoverImage($book, $coverImageFile); - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book'])->assertRedirect(); /** @var Book $copy */ $copy = Book::query()->where('name', '=', 'My copy book')->first(); - $this->assertNotNull($copy->cover); - $this->assertNotEquals($book->cover->id, $copy->cover->id); + $this->assertNotNull($copy->coverInfo()->getImage()); + $this->assertNotEquals($book->coverInfo()->getImage()->id, $copy->coverInfo()->getImage()->id); } public function test_copy_adds_book_to_shelves_if_edit_permissions_allows() diff --git a/tests/Entity/ConvertTest.php b/tests/Entity/ConvertTest.php index d9b1ee466cf..8658e76998b 100644 --- a/tests/Entity/ConvertTest.php +++ b/tests/Entity/ConvertTest.php @@ -35,8 +35,8 @@ public function test_convert_chapter_to_book() /** @var Book $newBook */ $newBook = Book::query()->orderBy('id', 'desc')->first(); - $this->assertDatabaseMissing('chapters', ['id' => $chapter->id]); - $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); + $this->assertDatabaseMissing('entities', ['id' => $chapter->id, 'type' => 'chapter']); + $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); $this->assertCount(1, $newBook->tags); $this->assertEquals('Category', $newBook->tags->first()->name); $this->assertEquals('Penguins', $newBook->tags->first()->value); @@ -100,7 +100,7 @@ public function test_book_convert_to_shelf() // Checks for new shelf $resp->assertRedirectContains('/shelves/'); - $this->assertDatabaseMissing('chapters', ['id' => $childChapter->id]); + $this->assertDatabaseMissing('entities', ['id' => $childChapter->id, 'type' => 'chapter']); $this->assertCount(1, $newShelf->tags); $this->assertEquals('Category', $newShelf->tags->first()->name); $this->assertEquals('Ducks', $newShelf->tags->first()->value); @@ -112,8 +112,8 @@ public function test_book_convert_to_shelf() $this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf); // Checks for old book to contain child pages - $this->assertDatabaseHas('books', ['id' => $book->id, 'name' => $book->name . ' Pages']); - $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => 0]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'name' => $book->name . ' Pages']); + $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => null]); // Checks for nested page $chapterChildPage->refresh(); diff --git a/tests/Entity/DefaultTemplateTest.php b/tests/Entity/DefaultTemplateTest.php index 5369a5430bc..d3109c8a2fe 100644 --- a/tests/Entity/DefaultTemplateTest.php +++ b/tests/Entity/DefaultTemplateTest.php @@ -18,7 +18,7 @@ public function test_creating_book_with_default_template() ]; $this->asEditor()->post('/books', $details); - $this->assertDatabaseHas('books', $details); + $this->assertDatabaseHasEntityData('book', $details); } public function test_creating_chapter_with_default_template() @@ -31,7 +31,7 @@ public function test_creating_chapter_with_default_template() ]; $this->asEditor()->post($book->getUrl('/create-chapter'), $details); - $this->assertDatabaseHas('chapters', $details); + $this->assertDatabaseHasEntityData('chapter', $details); } public function test_updating_book_with_default_template() @@ -40,10 +40,10 @@ public function test_updating_book_with_default_template() $templatePage = $this->entities->templatePage(); $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => strval($templatePage->id)]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]); $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => '']); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_updating_chapter_with_default_template() @@ -52,10 +52,10 @@ public function test_updating_chapter_with_default_template() $templatePage = $this->entities->templatePage(); $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => strval($templatePage->id)]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => '']); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } public function test_default_book_template_cannot_be_set_if_not_a_template() @@ -65,7 +65,7 @@ public function test_default_book_template_cannot_be_set_if_not_a_template() $this->assertFalse($page->template); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $page->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_default_chapter_template_cannot_be_set_if_not_a_template() @@ -75,7 +75,7 @@ public function test_default_chapter_template_cannot_be_set_if_not_a_template() $this->assertFalse($page->template); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $page->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } @@ -86,7 +86,7 @@ public function test_default_book_template_cannot_be_set_if_not_have_access() $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_default_chapter_template_cannot_be_set_if_not_have_access() @@ -96,7 +96,7 @@ public function test_default_chapter_template_cannot_be_set_if_not_have_access() $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } public function test_inaccessible_book_default_template_can_be_set_if_unchanged() @@ -106,7 +106,7 @@ public function test_inaccessible_book_default_template_can_be_set_if_unchanged( $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]); } public function test_inaccessible_chapter_default_template_can_be_set_if_unchanged() @@ -116,7 +116,7 @@ public function test_inaccessible_chapter_default_template_can_be_set_if_unchang $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); } public function test_default_page_template_option_shows_on_book_form() @@ -173,7 +173,7 @@ public function test_creating_book_page_uses_book_default_template() $templatePage->forceFill(['html' => '

My template page

', 'markdown' => '# My template page'])->save(); $book = $this->bookUsingDefaultTemplate($templatePage); - $this->asEditor()->get($book->getUrl('/create-page')); + $this->asEditor()->get($book->getUrl('/create-page'))->assertRedirect(); $latestPage = $book->pages() ->where('draft', '=', true) ->where('template', '=', false) @@ -251,7 +251,7 @@ public function test_creating_page_as_guest_uses_default_template() $this->post($book->getUrl('/create-guest-page'), [ 'name' => 'My guest page with template' - ]); + ])->assertRedirect(); $latestBookPage = $book->pages() ->where('draft', '=', false) ->where('template', '=', false) diff --git a/tests/Entity/PageDraftTest.php b/tests/Entity/PageDraftTest.php index e99ba9b8189..2623acd3f42 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -204,7 +204,7 @@ public function test_updating_page_draft_with_markdown_retains_markdown_content( ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $draft->id, 'draft' => true, 'name' => 'My updated draft', @@ -235,7 +235,7 @@ public function test_slug_generated_on_draft_publish_to_page_when_no_name_change 'markdown' => '# My markdown page', ]); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $draft->id, 'draft' => false, 'slug' => 'my-page', diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php index ad753c96647..d98b1f998df 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -85,7 +85,7 @@ public function test_empty_markdown_still_saves_without_error() $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details); $resp->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'markdown' => $details['markdown'], 'id' => $draft->id, 'draft' => false, diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index 9040254f78f..3828bd06e4a 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -91,7 +91,7 @@ public function test_page_revision_restore_with_markdown_retains_markdown_conten $restoreReq->assertRedirect($page->getUrl()); $pageView = $this->get($page->getUrl()); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'markdown' => '## New Content def456', ]); diff --git a/tests/Entity/PageTemplateTest.php b/tests/Entity/PageTemplateTest.php index 6a68c3ab18f..9c867a534fa 100644 --- a/tests/Entity/PageTemplateTest.php +++ b/tests/Entity/PageTemplateTest.php @@ -35,7 +35,7 @@ public function test_manage_templates_permission_required_to_change_page_templat ]; $this->put($page->getUrl(), $pageUpdateData); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'template' => false, ]); @@ -43,7 +43,7 @@ public function test_manage_templates_permission_required_to_change_page_templat $this->permissions->grantUserRolePermissions($editor, ['templates-manage']); $this->put($page->getUrl(), $pageUpdateData); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'template' => true, ]); diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index d2c448bf4b7..6994144626e 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -74,7 +74,7 @@ public function test_page_creation_with_markdown_content() $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details); $resp->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'markdown' => $details['markdown'], 'name' => $details['name'], 'id' => $draft->id, @@ -242,7 +242,7 @@ public function test_page_can_be_copied_without_edit_permission() ]); $movePageResp->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'name' => 'My copied test page', 'created_by' => $viewer->id, 'book_id' => $newBook->id, diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php index 3bccd468270..6bf585d5903 100644 --- a/tests/Exports/MarkdownExportTest.php +++ b/tests/Exports/MarkdownExportTest.php @@ -84,7 +84,7 @@ public function test_book_markdown_export_concats_immediate_pages_with_newlines( $this->asEditor()->get($book->getUrl('/create-page')); $this->get($book->getUrl('/create-page')); - [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get(); + [$pageA, $pageB] = $book->pages()->whereNull('chapter_id')->get(); $pageA->html = '

hello tester

'; $pageA->save(); $pageB->name = 'The second page in this test'; diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 1310dcc2456..692a5910f3c 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -227,7 +227,7 @@ public function test_book_export() $bookData = $zip->data['book']; $this->assertEquals($book->id, $bookData['id']); $this->assertEquals($book->name, $bookData['name']); - $this->assertEquals($book->descriptionHtml(), $bookData['description_html']); + $this->assertEquals($book->descriptionInfo()->getHtml(), $bookData['description_html']); $this->assertCount(2, $bookData['tags']); $this->assertCount($book->directPages()->count(), $bookData['pages']); $this->assertCount($book->chapters()->count(), $bookData['chapters']); @@ -240,7 +240,7 @@ public function test_book_export_with_cover_image() $bookRepo = $this->app->make(BookRepo::class); $coverImageFile = $this->files->uploadedImage('cover.png'); $bookRepo->updateCoverImage($book, $coverImageFile); - $coverImage = $book->cover()->first(); + $coverImage = $book->coverInfo()->getImage(); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); $zip = ZipTestHelper::extractFromZipResponse($zipResp); @@ -264,7 +264,7 @@ public function test_chapter_export() $chapterData = $zip->data['chapter']; $this->assertEquals($chapter->id, $chapterData['id']); $this->assertEquals($chapter->name, $chapterData['name']); - $this->assertEquals($chapter->descriptionHtml(), $chapterData['description_html']); + $this->assertEquals($chapter->descriptionInfo()->getHtml(), $chapterData['description_html']); $this->assertCount(2, $chapterData['tags']); $this->assertEquals($chapter->priority, $chapterData['priority']); $this->assertCount($chapter->pages()->count(), $chapterData['pages']); diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php index 68ffb4231bd..2255e16c393 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -109,7 +109,7 @@ public function test_book_import() // Book checks $this->assertEquals('Import test', $book->name); - $this->assertFileExists(public_path($book->cover->path)); + $this->assertFileExists(public_path($book->coverInfo()->getImage()->path)); $this->assertCount(2, $book->tags); $this->assertEquals('Cat', $book->tags()->first()->value); $this->assertCount(2, $book->chapters); diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php index c794f9478ab..5163cef14a0 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -50,7 +50,7 @@ public function pageWithinChapter(): Page public function pageNotWithinChapter(): Page { - return $this->page(fn(Builder $query) => $query->where('chapter_id', '=', 0)); + return $this->page(fn(Builder $query) => $query->whereNull('chapter_id')); } public function templatePage(): Page diff --git a/tests/Meta/OpenGraphTest.php b/tests/Meta/OpenGraphTest.php index 96e622da052..b3571635996 100644 --- a/tests/Meta/OpenGraphTest.php +++ b/tests/Meta/OpenGraphTest.php @@ -49,7 +49,7 @@ public function test_book_tags() $resp = $this->asEditor()->get($book->getUrl()); $tags = $this->getOpenGraphTags($resp); - $this->assertEquals($book->getBookCover(), $tags['image']); + $this->assertEquals($book->coverInfo()->getUrl(), $tags['image']); } public function test_shelf_tags() @@ -69,7 +69,7 @@ public function test_shelf_tags() $resp = $this->asEditor()->get($shelf->getUrl()); $tags = $this->getOpenGraphTags($resp); - $this->assertEquals($shelf->getBookCover(), $tags['image']); + $this->assertEquals($shelf->coverInfo()->getUrl(), $tags['image']); } /** diff --git a/tests/Permissions/EntityOwnerChangeTest.php b/tests/Permissions/EntityOwnerChangeTest.php index f002549220b..fd3f2797256 100644 --- a/tests/Permissions/EntityOwnerChangeTest.php +++ b/tests/Permissions/EntityOwnerChangeTest.php @@ -13,7 +13,7 @@ public function test_changing_page_owner() $user = User::query()->where('id', '!=', $page->owned_by)->first(); $this->asAdmin()->put($page->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('pages', ['owned_by' => $user->id, 'id' => $page->id]); + $this->assertDatabaseHasEntityData('page', ['owned_by' => $user->id, 'id' => $page->id]); } public function test_changing_chapter_owner() @@ -22,7 +22,7 @@ public function test_changing_chapter_owner() $user = User::query()->where('id', '!=', $chapter->owned_by)->first(); $this->asAdmin()->put($chapter->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('chapters', ['owned_by' => $user->id, 'id' => $chapter->id]); + $this->assertDatabaseHasEntityData('chapter', ['owned_by' => $user->id, 'id' => $chapter->id]); } public function test_changing_book_owner() @@ -31,7 +31,7 @@ public function test_changing_book_owner() $user = User::query()->where('id', '!=', $book->owned_by)->first(); $this->asAdmin()->put($book->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('books', ['owned_by' => $user->id, 'id' => $book->id]); + $this->assertDatabaseHasEntityData('book', ['owned_by' => $user->id, 'id' => $book->id]); } public function test_changing_shelf_owner() @@ -40,6 +40,6 @@ public function test_changing_shelf_owner() $user = User::query()->where('id', '!=', $shelf->owned_by)->first(); $this->asAdmin()->put($shelf->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('bookshelves', ['owned_by' => $user->id, 'id' => $shelf->id]); + $this->assertDatabaseHasEntityData('bookshelf', ['owned_by' => $user->id, 'id' => $shelf->id]); } } diff --git a/tests/Permissions/EntityPermissionsTest.php b/tests/Permissions/EntityPermissionsTest.php index ec2756b1213..d399e0c3463 100644 --- a/tests/Permissions/EntityPermissionsTest.php +++ b/tests/Permissions/EntityPermissionsTest.php @@ -629,10 +629,8 @@ public function test_page_visible_if_has_permissions_when_book_not_visible() public function test_book_sort_view_permission() { - /** @var Book $firstBook */ - $firstBook = Book::query()->first(); - /** @var Book $secondBook */ - $secondBook = Book::query()->find(2); + $firstBook = $this->entities->book(); + $secondBook = $this->entities->book(); $this->setRestrictionsForTestRoles($firstBook, ['view', 'update']); $this->setRestrictionsForTestRoles($secondBook, ['view']); diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index 76745aaac72..e6fc7a6a3c5 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -104,7 +104,7 @@ public function test_public_page_creation() $resp->assertRedirect($chapter->book->getUrl('/page/my-guest-page/edit')); $user = $this->users->guest(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'name' => 'My guest page', 'chapter_id' => $chapter->id, 'created_by' => $user->id, diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php index f8698d02885..389f164a9ef 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -259,7 +259,7 @@ public function test_description_links_from_book_chapter_shelf_updated_on_url_ch } $oldUrl = $shelf->getUrl(); - $this->put($shelf->getUrl(), ['name' => 'My updated shelf link']); + $this->put($shelf->getUrl(), ['name' => 'My updated shelf link'])->assertRedirect(); $shelf->refresh(); $this->assertNotEquals($oldUrl, $shelf->getUrl()); diff --git a/tests/Settings/RecycleBinTest.php b/tests/Settings/RecycleBinTest.php index 33284b4b3ff..c17cfed97ea 100644 --- a/tests/Settings/RecycleBinTest.php +++ b/tests/Settings/RecycleBinTest.php @@ -3,6 +3,7 @@ namespace Tests\Settings; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Deletion; use BookStack\Entities\Models\Page; use Illuminate\Support\Carbon; @@ -82,10 +83,12 @@ public function test_recycle_bin_empty() $emptyReq->assertRedirect('/settings/recycle-bin'); $this->assertTrue(Deletion::query()->count() === 0); - $this->assertDatabaseMissing('books', ['id' => $book->id]); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); - $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]); - $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']); + $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']); + $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']); $itemCount = 2 + $book->pages->count() + $book->chapters->count(); $redirectReq = $this->get('/settings/recycle-bin'); @@ -95,18 +98,18 @@ public function test_recycle_bin_empty() public function test_entity_restore() { $book = $this->entities->bookHasChaptersAndPages(); - $this->asEditor()->delete($book->getUrl()); + $this->asEditor()->delete($book->getUrl())->assertRedirect(); $deletion = Deletion::query()->firstOrFail(); - $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); - $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); + $this->assertEquals($book->pages->count(), Page::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); + $this->assertEquals($book->chapters->count(), Chapter::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); $restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore"); $restoreReq->assertRedirect('/settings/recycle-bin'); $this->assertTrue(Deletion::query()->count() === 0); - $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); - $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); + $this->assertEquals($book->pages->count(), Page::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); + $this->assertEquals($book->chapters->count(), Chapter::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); $itemCount = 1 + $book->pages->count() + $book->chapters->count(); $redirectReq = $this->get('/settings/recycle-bin'); @@ -123,9 +126,12 @@ public function test_permanent_delete() $deleteReq->assertRedirect('/settings/recycle-bin'); $this->assertTrue(Deletion::query()->count() === 0); - $this->assertDatabaseMissing('books', ['id' => $book->id]); - $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]); - $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']); + $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']); + $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']); $itemCount = 1 + $book->pages->count() + $book->chapters->count(); $redirectReq = $this->get('/settings/recycle-bin'); @@ -173,6 +179,34 @@ public function test_permanent_entity_delete_updates_existing_activity_with_enti ]); } + public function test_permanent_book_delete_removes_shelf_relation_data() + { + $book = $this->entities->book(); + $shelf = $this->entities->shelf(); + $shelf->books()->attach($book); + $this->assertDatabaseHas('bookshelves_books', ['book_id' => $book->id]); + + $this->asEditor()->delete($book->getUrl()); + $deletion = $book->deletions()->firstOrFail(); + $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect(); + + $this->assertDatabaseMissing('bookshelves_books', ['book_id' => $book->id]); + } + + public function test_permanent_shelf_delete_removes_book_relation_data() + { + $book = $this->entities->book(); + $shelf = $this->entities->shelf(); + $shelf->books()->attach($book); + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id]); + + $this->asEditor()->delete($shelf->getUrl()); + $deletion = $shelf->deletions()->firstOrFail(); + $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect(); + + $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]); + } + public function test_auto_clear_functionality_works() { config()->set('app.recycle_bin_lifetime', 5); @@ -180,14 +214,14 @@ public function test_auto_clear_functionality_works() $otherPage = $this->entities->page(); $this->asEditor()->delete($page->getUrl()); - $this->assertDatabaseHas('pages', ['id' => $page->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id]); $this->assertEquals(1, Deletion::query()->count()); Carbon::setTestNow(Carbon::now()->addDays(6)); $this->asEditor()->delete($otherPage->getUrl()); $this->assertEquals(1, Deletion::query()->count()); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); } public function test_auto_clear_functionality_with_negative_time_keeps_forever() @@ -203,7 +237,7 @@ public function test_auto_clear_functionality_with_negative_time_keeps_forever() $this->asEditor()->delete($otherPage->getUrl()); $this->assertEquals(2, Deletion::query()->count()); - $this->assertDatabaseHas('pages', ['id' => $page->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id]); } public function test_auto_clear_functionality_with_zero_time_deletes_instantly() @@ -212,7 +246,7 @@ public function test_auto_clear_functionality_with_zero_time_deletes_instantly() $page = $this->entities->page(); $this->asEditor()->delete($page->getUrl()); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); $this->assertEquals(0, Deletion::query()->count()); } diff --git a/tests/Sorting/BookSortTest.php b/tests/Sorting/BookSortTest.php index 4737ec231b6..7f31f9c2739 100644 --- a/tests/Sorting/BookSortTest.php +++ b/tests/Sorting/BookSortTest.php @@ -66,7 +66,7 @@ public function test_book_sort() $sortResp = $this->asEditor()->put($newBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)]); $sortResp->assertRedirect($newBook->getUrl()); $sortResp->assertStatus(302); - $this->assertDatabaseHas('chapters', [ + $this->assertDatabaseHasEntityData('chapter', [ 'id' => $chapterToMove->id, 'book_id' => $newBook->id, 'priority' => 0, @@ -93,7 +93,7 @@ public function test_book_sort_makes_no_changes_if_new_chapter_does_not_align_wi ]; $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -114,7 +114,7 @@ public function test_book_sort_makes_no_changes_if_no_view_permissions_on_new_ch ]; $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -136,7 +136,7 @@ public function test_book_sort_makes_no_changes_if_no_view_permissions_on_new_bo ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -158,7 +158,7 @@ public function test_book_sort_makes_no_changes_if_no_update_or_create_permissio ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -180,7 +180,7 @@ public function test_book_sort_makes_no_changes_if_no_update_permissions_on_move ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -202,7 +202,7 @@ public function test_book_sort_makes_no_changes_if_no_delete_permissions_on_move ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -211,7 +211,7 @@ public function test_book_sort_does_not_change_timestamps_on_just_order_changes( { $book = $this->entities->bookHasChaptersAndPages(); $chapter = $book->chapters()->first(); - \DB::table('chapters')->where('id', '=', $chapter->id)->update([ + Chapter::query()->where('id', '=', $chapter->id)->update([ 'priority' => 10001, 'updated_at' => \Carbon\Carbon::now()->subYear(5), ]); @@ -299,7 +299,7 @@ public function test_pages_in_book_show_sorted_by_priority() $book = $this->entities->bookHasChaptersAndPages(); $book->chapters()->forceDelete(); /** @var Page[] $pages */ - $pages = $book->pages()->where('chapter_id', '=', 0)->take(2)->get(); + $pages = $book->pages()->whereNull('chapter_id')->take(2)->get(); $book->pages()->whereNotIn('id', $pages->pluck('id'))->delete(); $resp = $this->asEditor()->get($book->getUrl()); diff --git a/tests/Sorting/MoveTest.php b/tests/Sorting/MoveTest.php index 606b23c680f..5a341026bf2 100644 --- a/tests/Sorting/MoveTest.php +++ b/tests/Sorting/MoveTest.php @@ -20,7 +20,7 @@ public function test_page_move_into_book() $movePageResp = $this->put($page->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, - ]); + ])->assertRedirect(); $page->refresh(); $movePageResp->assertRedirect($page->getUrl()); diff --git a/tests/Sorting/SortRuleTest.php b/tests/Sorting/SortRuleTest.php index 4a9d3a7b33f..a6be9beef1f 100644 --- a/tests/Sorting/SortRuleTest.php +++ b/tests/Sorting/SortRuleTest.php @@ -142,7 +142,7 @@ public function test_delete_requires_confirmation_if_books_assigned() $resp = $this->delete("settings/sorting/rules/{$rule->id}", ['confirm' => 'true']); $resp->assertRedirect('/settings/sorting'); $this->assertDatabaseMissing('sort_rules', ['id' => $rule->id]); - $this->assertDatabaseMissing('books', ['sort_rule_id' => $rule->id]); + $this->assertDatabaseMissing('entity_container_data', ['sort_rule_id' => $rule->id]); } public function test_page_create_triggers_book_sort() @@ -159,7 +159,7 @@ public function test_page_create_triggers_book_sort() ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => '1111 page', 'priority' => $book->chapters()->count() + 1, @@ -217,7 +217,7 @@ public function test_name_alphabetical_ordering() } foreach ($namesToAdd as $index => $name) { - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => $name, 'priority' => $index + 1, @@ -251,7 +251,7 @@ public function test_name_numeric_ordering() } foreach ($namesToAdd as $index => $name) { - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => $name, 'priority' => $index + 1, diff --git a/tests/TestCase.php b/tests/TestCase.php index a8636fb158e..2395317482e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,7 +6,7 @@ use BookStack\Http\HttpClientHistory; use BookStack\Http\HttpRequestService; use BookStack\Settings\SettingService; -use BookStack\Users\Models\User; +use Exception; use Illuminate\Contracts\Console\Kernel; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Testing\Assert as PHPUnit; +use Illuminate\Testing\Constraints\HasInDatabase; use Monolog\Handler\TestHandler; use Monolog\Logger; use Ssddanbrown\AssertHtml\TestsHtml; @@ -267,4 +268,42 @@ protected function assertActivityExists(string $type, ?Entity $entity = null, st $this->assertDatabaseHas('activities', $detailsToCheck); } + + /** + * Assert the database has the given data for an entity type. + */ + protected function assertDatabaseHasEntityData(string $type, array $data = []): self + { + $entityFields = array_intersect_key($data, array_flip(Entity::$commonFields)); + $extraFields = array_diff_key($data, $entityFields); + $extraTable = $type === 'page' ? 'entity_page_data' : 'entity_container_data'; + $entityFields['type'] = $type; + + $this->assertThat( + $this->getTable('entities'), + new HasInDatabase($this->getConnection(null, 'entities'), $entityFields) + ); + + if (!empty($extraFields)) { + $id = $entityFields['id'] ?? DB::table($this->getTable('entities')) + ->where($entityFields)->orderByDesc('id')->first()->id ?? null; + if (is_null($id)) { + throw new Exception('Failed to find entity id for asserting database data'); + } + + if ($type !== 'page') { + $extraFields['entity_id'] = $id; + $extraFields['entity_type'] = $type; + } else { + $extraFields['page_id'] = $id; + } + + $this->assertThat( + $this->getTable($extraTable), + new HasInDatabase($this->getConnection(null, $extraTable), $extraFields) + ); + } + + return $this; + } } diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index d92f13f0b3f..6d8b4d75a53 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -165,8 +165,8 @@ public function test_delete_with_new_owner_id_changes_ownership() $owner = $page->ownedBy; $newOwner = User::query()->where('id', '!=', $owner->id)->first(); - $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id]); - $this->assertDatabaseHas('pages', [ + $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id])->assertRedirect(); + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'owned_by' => $newOwner->id, ]); From 5754acf2fb9fcb60bb2ffd95bab3b0bcb1b162fd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Oct 2025 19:10:15 +0100 Subject: [PATCH 003/398] DB: Updated handling of deleted user ID handling in DB Updated uses of user ID to nullify on delete. Added testing to cover deletion of user relations. Added model factories to support changes and potential other tests. Cleans existing ID references in the DB via migration. --- app/Access/Mfa/MfaValue.php | 3 + app/Access/SocialAccount.php | 13 +- app/Activity/Models/Activity.php | 3 + app/Activity/Models/Favourite.php | 3 + app/Activity/Models/Watch.php | 3 + app/Entities/Models/Deletion.php | 3 + app/Entities/Models/PageRevision.php | 3 + app/Users/Models/User.php | 2 - app/Users/UserRepo.php | 65 ++++++--- .../factories/Access/Mfa/MfaValueFactory.php | 28 ++++ .../factories/Access/SocialAccountFactory.php | 29 ++++ .../Activity/Models/ActivityFactory.php | 34 +++++ .../Activity/Models/FavouriteFactory.php | 31 +++++ .../Activity/Models/WatchFactory.php | 33 +++++ .../Entities/Models/DeletionFactory.php | 29 ++++ .../factories/Entities/Models/PageFactory.php | 4 +- .../Entities/Models/PageRevisionFactory.php | 40 ++++++ ..._134751_update_entity_relation_columns.php | 3 +- ..._10_18_163331_clean_user_id_references.php | 80 +++++++++++ tests/User/UserManagementTest.php | 130 +++++++++++++++--- 20 files changed, 495 insertions(+), 44 deletions(-) create mode 100644 database/factories/Access/Mfa/MfaValueFactory.php create mode 100644 database/factories/Access/SocialAccountFactory.php create mode 100644 database/factories/Activity/Models/ActivityFactory.php create mode 100644 database/factories/Activity/Models/FavouriteFactory.php create mode 100644 database/factories/Activity/Models/WatchFactory.php create mode 100644 database/factories/Entities/Models/DeletionFactory.php create mode 100644 database/factories/Entities/Models/PageRevisionFactory.php create mode 100644 database/migrations/2025_10_18_163331_clean_user_id_references.php diff --git a/app/Access/Mfa/MfaValue.php b/app/Access/Mfa/MfaValue.php index 64d20eb18f6..dd3e04618f7 100644 --- a/app/Access/Mfa/MfaValue.php +++ b/app/Access/Mfa/MfaValue.php @@ -4,6 +4,7 @@ use BookStack\Users\Models\User; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; /** @@ -16,6 +17,8 @@ */ class MfaValue extends Model { + use HasFactory; + protected static $unguarded = true; const METHOD_TOTP = 'totp'; diff --git a/app/Access/SocialAccount.php b/app/Access/SocialAccount.php index b76dbb9ec85..f52f74cc48c 100644 --- a/app/Access/SocialAccount.php +++ b/app/Access/SocialAccount.php @@ -5,18 +5,23 @@ use BookStack\Activity\Models\Loggable; use BookStack\App\Model; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** - * Class SocialAccount. - * * @property string $driver * @property User $user */ class SocialAccount extends Model implements Loggable { - protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps']; + use HasFactory; - public function user() + protected $fillable = ['user_id', 'driver', 'driver_id']; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo { return $this->belongsTo(User::class); } diff --git a/app/Activity/Models/Activity.php b/app/Activity/Models/Activity.php index ac9fec5178b..898a6c93a09 100644 --- a/app/Activity/Models/Activity.php +++ b/app/Activity/Models/Activity.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Permissions\Models\JointPermission; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -24,6 +25,8 @@ */ class Activity extends Model { + use HasFactory; + /** * Get the loggable model related to this activity. * Currently only used for entities (previously entity_[id/type] columns). diff --git a/app/Activity/Models/Favourite.php b/app/Activity/Models/Favourite.php index 6f6079b0766..6b5e97deeee 100644 --- a/app/Activity/Models/Favourite.php +++ b/app/Activity/Models/Favourite.php @@ -4,11 +4,14 @@ use BookStack\App\Model; use BookStack\Permissions\Models\JointPermission; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; class Favourite extends Model { + use HasFactory; + protected $fillable = ['user_id']; /** diff --git a/app/Activity/Models/Watch.php b/app/Activity/Models/Watch.php index dfb72cc0a36..b088bd72458 100644 --- a/app/Activity/Models/Watch.php +++ b/app/Activity/Models/Watch.php @@ -5,6 +5,7 @@ use BookStack\Activity\WatchLevels; use BookStack\Permissions\Models\JointPermission; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -20,6 +21,8 @@ */ class Watch extends Model { + use HasFactory; + protected $guarded = []; public function watchable(): MorphTo diff --git a/app/Entities/Models/Deletion.php b/app/Entities/Models/Deletion.php index 55589f61ec9..c24c72d442a 100644 --- a/app/Entities/Models/Deletion.php +++ b/app/Entities/Models/Deletion.php @@ -4,6 +4,7 @@ use BookStack\Activity\Models\Loggable; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -17,6 +18,8 @@ */ class Deletion extends Model implements Loggable { + use HasFactory; + protected $hidden = []; /** diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php index 1a6c980e109..4409afdc222 100644 --- a/app/Entities/Models/PageRevision.php +++ b/app/Entities/Models/PageRevision.php @@ -6,6 +6,7 @@ use BookStack\App\Model; use BookStack\Users\Models\User; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -30,6 +31,8 @@ */ class PageRevision extends Model implements Loggable { + use HasFactory; + protected $fillable = ['name', 'text', 'summary']; protected $hidden = ['html', 'markdown', 'text']; diff --git a/app/Users/Models/User.php b/app/Users/Models/User.php index 0017653b53d..8bbf11695e2 100644 --- a/app/Users/Models/User.php +++ b/app/Users/Models/User.php @@ -31,8 +31,6 @@ use Illuminate\Support\Collection; /** - * Class User. - * * @property int $id * @property string $name * @property string $slug diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php index 79d9e1b9eb2..7a4fa5f98d2 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -5,8 +5,6 @@ use BookStack\Access\UserInviteException; use BookStack\Access\UserInviteService; use BookStack\Activity\ActivityType; -use BookStack\Entities\EntityProvider; -use BookStack\Entities\Models\Entity; use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\UserUpdateException; use BookStack\Facades\Activity; @@ -27,7 +25,6 @@ public function __construct( ) { } - /** * Get a user by their email address. */ @@ -161,15 +158,12 @@ public function update(User $user, array $data, bool $manageUsersAllowed): User * * @throws Exception */ - public function destroy(User $user, ?int $newOwnerId = null) + public function destroy(User $user, ?int $newOwnerId = null): void { $this->ensureDeletable($user); - $user->socialAccounts()->delete(); - $user->apiTokens()->delete(); - $user->favourites()->delete(); - $user->mfaValues()->delete(); - $user->watches()->delete(); + $this->removeUserDependantRelations($user); + $this->nullifyUserNonDependantRelations($user); $user->delete(); // Delete user profile images @@ -178,17 +172,53 @@ public function destroy(User $user, ?int $newOwnerId = null) // Delete related activities setting()->deleteUserSettings($user->id); + // Migrate or nullify ownership + $newOwner = null; if (!empty($newOwnerId)) { $newOwner = User::query()->find($newOwnerId); - if (!is_null($newOwner)) { - $this->migrateOwnership($user, $newOwner); - } - // TODO - Should be be nullifying ownership instead? } + $this->migrateOwnership($user, $newOwner); Activity::add(ActivityType::USER_DELETE, $user); } + protected function removeUserDependantRelations(User $user): void + { + $user->apiTokens()->delete(); + $user->socialAccounts()->delete(); + $user->favourites()->delete(); + $user->mfaValues()->delete(); + $user->watches()->delete(); + + $tables = ['email_confirmations', 'user_invites', 'views']; + foreach ($tables as $table) { + DB::table($table)->where('user_id', '=', $user->id)->delete(); + } + } + protected function nullifyUserNonDependantRelations(User $user): void + { + $toNullify = [ + 'activities' => ['user_id'], + 'attachments' => ['created_by', 'updated_by'], + 'comments' => ['created_by', 'updated_by'], + 'deletions' => ['deleted_by'], + 'entities' => ['created_by', 'updated_by'], + 'images' => ['created_by', 'updated_by'], + 'imports' => ['created_by'], + 'joint_permissions' => ['owner_id'], + 'page_revisions' => ['created_by'], + 'sessions' => ['user_id'], + ]; + + foreach ($toNullify as $table => $columns) { + foreach ($columns as $column) { + DB::table($table) + ->where($column, '=', $user->id) + ->update([$column => null]); + } + } + } + /** * @throws NotifyException */ @@ -206,11 +236,12 @@ protected function ensureDeletable(User $user): void /** * Migrate ownership of items in the system from one user to another. */ - protected function migrateOwnership(User $fromUser, User $toUser): void + protected function migrateOwnership(User $fromUser, User|null $toUser): void { + $newOwnerValue = $toUser ? $toUser->id : null; DB::table('entities') ->where('owned_by', '=', $fromUser->id) - ->update(['owned_by' => $toUser->id]); + ->update(['owned_by' => $newOwnerValue]); } /** @@ -248,7 +279,7 @@ protected function isOnlyAdmin(User $user): bool * * @throws UserUpdateException */ - protected function setUserRoles(User $user, array $roles) + protected function setUserRoles(User $user, array $roles): void { $roles = array_filter(array_values($roles)); @@ -261,7 +292,7 @@ protected function setUserRoles(User $user, array $roles) /** * Check if the given user is the last admin and their new roles no longer - * contains the admin role. + * contain the admin role. */ protected function demotingLastAdmin(User $user, array $newRoles): bool { diff --git a/database/factories/Access/Mfa/MfaValueFactory.php b/database/factories/Access/Mfa/MfaValueFactory.php new file mode 100644 index 00000000000..03036b6086e --- /dev/null +++ b/database/factories/Access/Mfa/MfaValueFactory.php @@ -0,0 +1,28 @@ + + */ +class MfaValueFactory extends Factory +{ + protected $model = \BookStack\Access\Mfa\MfaValue::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'method' => 'totp', + 'value' => '123456', + ]; + } +} diff --git a/database/factories/Access/SocialAccountFactory.php b/database/factories/Access/SocialAccountFactory.php new file mode 100644 index 00000000000..814f47b5884 --- /dev/null +++ b/database/factories/Access/SocialAccountFactory.php @@ -0,0 +1,29 @@ + + */ +class SocialAccountFactory extends Factory +{ + protected $model = \BookStack\Access\SocialAccount::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'driver' => 'github', + 'driver_id' => '123456', + 'avatar' => '', + ]; + } +} diff --git a/database/factories/Activity/Models/ActivityFactory.php b/database/factories/Activity/Models/ActivityFactory.php new file mode 100644 index 00000000000..06ec07ced06 --- /dev/null +++ b/database/factories/Activity/Models/ActivityFactory.php @@ -0,0 +1,34 @@ + + */ +class ActivityFactory extends Factory +{ + protected $model = \BookStack\Activity\Models\Activity::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $activities = ActivityType::all(); + $activity = $activities[array_rand($activities)]; + return [ + 'type' => $activity, + 'detail' => 'Activity detail for ' . $activity, + 'user_id' => User::factory(), + 'ip' => $this->faker->ipv4(), + 'loggable_id' => null, + 'loggable_type' => null, + ]; + } +} diff --git a/database/factories/Activity/Models/FavouriteFactory.php b/database/factories/Activity/Models/FavouriteFactory.php new file mode 100644 index 00000000000..75fba86b371 --- /dev/null +++ b/database/factories/Activity/Models/FavouriteFactory.php @@ -0,0 +1,31 @@ + + */ +class FavouriteFactory extends Factory +{ + protected $model = \BookStack\Activity\Models\Favourite::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $book = Book::query()->first(); + + return [ + 'user_id' => User::factory(), + 'favouritable_id' => $book->id, + 'favouritable_type' => 'book', + ]; + } +} diff --git a/database/factories/Activity/Models/WatchFactory.php b/database/factories/Activity/Models/WatchFactory.php new file mode 100644 index 00000000000..0f8b9e6f759 --- /dev/null +++ b/database/factories/Activity/Models/WatchFactory.php @@ -0,0 +1,33 @@ + + */ +class WatchFactory extends Factory +{ + protected $model = \BookStack\Activity\Models\Watch::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $book = Book::factory()->create(); + + return [ + 'user_id' => User::factory(), + 'watchable_id' => $book->id, + 'watchable_type' => 'book', + 'level' => WatchLevels::NEW, + ]; + } +} diff --git a/database/factories/Entities/Models/DeletionFactory.php b/database/factories/Entities/Models/DeletionFactory.php new file mode 100644 index 00000000000..2368447cd72 --- /dev/null +++ b/database/factories/Entities/Models/DeletionFactory.php @@ -0,0 +1,29 @@ + + */ +class DeletionFactory extends Factory +{ + protected $model = \BookStack\Entities\Models\Deletion::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'deleted_by' => User::factory(), + 'deletable_id' => Page::factory(), + 'deletable_type' => 'page', + ]; + } +} diff --git a/database/factories/Entities/Models/PageFactory.php b/database/factories/Entities/Models/PageFactory.php index 47e5aa5db2b..538ee5f3ec2 100644 --- a/database/factories/Entities/Models/PageFactory.php +++ b/database/factories/Entities/Models/PageFactory.php @@ -17,10 +17,8 @@ class PageFactory extends Factory /** * Define the model's default state. - * - * @return array */ - public function definition() + public function definition(): array { $html = '

' . implode('

', $this->faker->paragraphs(5)) . '

'; diff --git a/database/factories/Entities/Models/PageRevisionFactory.php b/database/factories/Entities/Models/PageRevisionFactory.php new file mode 100644 index 00000000000..333916931f5 --- /dev/null +++ b/database/factories/Entities/Models/PageRevisionFactory.php @@ -0,0 +1,40 @@ +' . implode('

', $this->faker->paragraphs(5)) . '

'; + $page = Page::query()->first(); + + return [ + 'page_id' => $page->id, + 'name' => $this->faker->sentence(), + 'html' => $html, + 'text' => strip_tags($html), + 'created_by' => User::factory(), + 'slug' => $page->slug, + 'book_slug' => $page->book->slug, + 'type' => 'version', + 'markdown' => strip_tags($html), + 'summary' => $this->faker->sentence(), + 'revision_number' => rand(1, 4000), + ]; + } +} diff --git a/database/migrations/2025_09_15_134751_update_entity_relation_columns.php b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php index 267cd49f551..6fbeb1dd111 100644 --- a/database/migrations/2025_09_15_134751_update_entity_relation_columns.php +++ b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php @@ -62,8 +62,9 @@ public function up(): void }); } - // Convert image zero values to null + // Convert image and activity zero values to null DB::table('images')->where('uploaded_to', '=', 0)->update(['uploaded_to' => null]); + DB::table('activities')->where('loggable_id', '=', 0)->update(['loggable_id' => null]); // Rebuild joint permissions if needed // This was moved here from 2023_01_24_104625_refactor_joint_permissions_storage since the changes diff --git a/database/migrations/2025_10_18_163331_clean_user_id_references.php b/database/migrations/2025_10_18_163331_clean_user_id_references.php new file mode 100644 index 00000000000..75ff6af33cb --- /dev/null +++ b/database/migrations/2025_10_18_163331_clean_user_id_references.php @@ -0,0 +1,80 @@ + ['user_id'], + 'attachments' => ['created_by', 'updated_by'], + 'comments' => ['created_by', 'updated_by'], + 'deletions' => ['deleted_by'], + 'entities' => ['created_by', 'updated_by', 'owned_by'], + 'images' => ['created_by', 'updated_by'], + 'imports' => ['created_by'], + 'joint_permissions' => ['owner_id'], + 'page_revisions' => ['created_by'], + ]; + + protected static array $toClean = [ + 'api_tokens' => ['user_id'], + 'email_confirmations' => ['user_id'], + 'favourites' => ['user_id'], + 'mfa_values' => ['user_id'], + 'role_user' => ['user_id'], + 'sessions' => ['user_id'], + 'social_accounts' => ['user_id'], + 'user_invites' => ['user_id'], + 'views' => ['user_id'], + 'watches' => ['user_id'], + ]; + + /** + * Run the migrations. + */ + public function up(): void + { + $idSelectQuery = DB::table('users')->select('id'); + + foreach (self::$toNullify as $tableName => $columns) { + Schema::table($tableName, function (Blueprint $table) use ($columns) { + foreach ($columns as $columnName) { + $table->unsignedInteger($columnName)->nullable()->change(); + } + }); + + foreach ($columns as $columnName) { + DB::table($tableName)->where($columnName, '=', 0)->update([$columnName => null]); + DB::table($tableName)->whereNotIn($columnName, $idSelectQuery)->update([$columnName => null]); + } + } + + foreach (self::$toClean as $tableName => $columns) { + foreach ($columns as $columnName) { + DB::table($tableName)->whereNotIn($columnName, $idSelectQuery)->delete(); + } + } + + // TODO - Ensure each is fully handled in user delete + // Start by writing tests for each of these columns + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach (self::$toNullify as $tableName => $columns) { + foreach ($columns as $columnName) { + DB::table($tableName)->whereNull($columnName)->update([$columnName => 0]); + } + + Schema::table($tableName, function (Blueprint $table) use ($columns) { + foreach ($columns as $columnName) { + $table->unsignedInteger($columnName)->nullable(false)->change(); + } + }); + } + } +}; diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index 6d8b4d75a53..a34a9e7f4c4 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -2,9 +2,21 @@ namespace Tests\User; +use BookStack\Access\Mfa\MfaValue; +use BookStack\Access\SocialAccount; use BookStack\Access\UserInviteException; use BookStack\Access\UserInviteService; use BookStack\Activity\ActivityType; +use BookStack\Activity\Models\Activity; +use BookStack\Activity\Models\Comment; +use BookStack\Activity\Models\Favourite; +use BookStack\Activity\Models\View; +use BookStack\Activity\Models\Watch; +use BookStack\Api\ApiToken; +use BookStack\Entities\Models\Deletion; +use BookStack\Entities\Models\PageRevision; +use BookStack\Exports\Import; +use BookStack\Uploads\Attachment; use BookStack\Uploads\Image; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; @@ -28,10 +40,10 @@ public function test_user_creation() $this->withHtml($resp)->assertElementContains('form[action="' . url('/settings/users/create') . '"]', 'Save'); $resp = $this->post('/settings/users/create', [ - 'name' => $user->name, - 'email' => $user->email, - 'password' => $user->password, - 'password-confirm' => $user->password, + 'name' => $user->name, + 'email' => $user->email, + 'password' => $user->password, + 'password-confirm' => $user->password, 'roles[' . $adminRole->id . ']' => 'true', ]); $resp->assertRedirect('/settings/users'); @@ -77,7 +89,7 @@ public function test_user_password_update() $this->get($userProfilePage)->assertSee('Password confirmation required'); $this->put($userProfilePage, [ - 'password' => 'newpassword', + 'password' => 'newpassword', 'password-confirm' => 'newpassword', ])->assertRedirect('/settings/users'); @@ -167,7 +179,7 @@ public function test_delete_with_new_owner_id_changes_ownership() $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id])->assertRedirect(); $this->assertDatabaseHasEntityData('page', [ - 'id' => $page->id, + 'id' => $page->id, 'owned_by' => $newOwner->id, ]); } @@ -182,6 +194,90 @@ public function test_delete_with_empty_owner_migration_id_works() $this->assertSessionHas('success'); } + public function test_delete_with_empty_owner_migration_id_clears_relevant_id_uses() + { + $user = $this->users->editor(); + $page = $this->entities->page(); + $this->actingAs($user); + + // Create relations + $activity = Activity::factory()->create(['user_id' => $user->id]); + $attachment = Attachment::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]); + $comment = Comment::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]); + $deletion = Deletion::factory()->create(['deleted_by' => $user->id]); + $page->forceFill(['owned_by' => $user->id, 'created_by' => $user->id, 'updated_by' => $user->id])->save(); + $page->rebuildPermissions(); + $image = Image::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]); + $import = Import::factory()->create(['created_by' => $user->id]); + $revision = PageRevision::factory()->create(['created_by' => $user->id]); + + $apiToken = ApiToken::factory()->create(['user_id' => $user->id]); + \DB::table('email_confirmations')->insert(['user_id' => $user->id, 'token' => 'abc123']); + $favourite = Favourite::factory()->create(['user_id' => $user->id]); + $mfaValue = MfaValue::factory()->create(['user_id' => $user->id]); + $socialAccount = SocialAccount::factory()->create(['user_id' => $user->id]); + \DB::table('user_invites')->insert(['user_id' => $user->id, 'token' => 'abc123']); + View::incrementFor($page); + $watch = Watch::factory()->create(['user_id' => $user->id]); + + $userColumnsByTable = [ + 'activities' => ['user_id'], + 'api_tokens' => ['user_id'], + 'attachments' => ['created_by', 'updated_by'], + 'comments' => ['created_by', 'updated_by'], + 'deletions' => ['deleted_by'], + 'email_confirmations' => ['user_id'], + 'entities' => ['created_by', 'updated_by', 'owned_by'], + 'favourites' => ['user_id'], + 'images' => ['created_by', 'updated_by'], + 'imports' => ['created_by'], + 'joint_permissions' => ['owner_id'], + 'mfa_values' => ['user_id'], + 'page_revisions' => ['created_by'], + 'role_user' => ['user_id'], + 'social_accounts' => ['user_id'], + 'user_invites' => ['user_id'], + 'views' => ['user_id'], + 'watches' => ['user_id'], + ]; + + // Ensure columns have user id before deletion + foreach ($userColumnsByTable as $table => $columns) { + foreach ($columns as $column) { + $this->assertDatabaseHas($table, [$column => $user->id]); + } + } + + $resp = $this->asAdmin()->delete("settings/users/{$user->id}", ['new_owner_id' => '']); + $resp->assertRedirect('/settings/users'); + + // Ensure columns missing user id after deletion + foreach ($userColumnsByTable as $table => $columns) { + foreach ($columns as $column) { + $this->assertDatabaseMissing($table, [$column => $user->id]); + } + } + + // Check models exist where should be retained + $this->assertDatabaseHas('activities', ['id' => $activity->id, 'user_id' => null]); + $this->assertDatabaseHas('attachments', ['id' => $attachment->id, 'created_by' => null, 'updated_by' => null]); + $this->assertDatabaseHas('comments', ['id' => $comment->id, 'created_by' => null, 'updated_by' => null]); + $this->assertDatabaseHas('deletions', ['id' => $deletion->id, 'deleted_by' => null]); + $this->assertDatabaseHas('entities', ['id' => $page->id, 'created_by' => null, 'updated_by' => null, 'owned_by' => null]); + $this->assertDatabaseHas('images', ['id' => $image->id, 'created_by' => null, 'updated_by' => null]); + $this->assertDatabaseHas('imports', ['id' => $import->id, 'created_by' => null]); + $this->assertDatabaseHas('page_revisions', ['id' => $revision->id, 'created_by' => null]); + + // Check models no longer exist where should have been deleted with the user + $this->assertDatabaseMissing('api_tokens', ['id' => $apiToken->id]); + $this->assertDatabaseMissing('email_confirmations', ['token' => 'abc123']); + $this->assertDatabaseMissing('favourites', ['id' => $favourite->id]); + $this->assertDatabaseMissing('mfa_values', ['id' => $mfaValue->id]); + $this->assertDatabaseMissing('social_accounts', ['id' => $socialAccount->id]); + $this->assertDatabaseMissing('user_invites', ['token' => 'abc123']); + $this->assertDatabaseMissing('watches', ['id' => $watch->id]); + } + public function test_delete_removes_user_preferences() { $editor = $this->users->editor(); @@ -247,9 +343,9 @@ public function test_user_creation_is_not_performed_if_the_invitation_sending_fa }); $this->asAdmin()->post('/settings/users/create', [ - 'name' => $user->name, - 'email' => $user->email, - 'send_invite' => 'true', + 'name' => $user->name, + 'email' => $user->email, + 'send_invite' => 'true', 'roles[' . $adminRole->id . ']' => 'true', ]); @@ -267,9 +363,9 @@ public function test_user_create_activity_is_not_persisted_if_the_invitation_sen }); $this->asAdmin()->post('/settings/users/create', [ - 'name' => $user->name, - 'email' => $user->email, - 'send_invite' => 'true', + 'name' => $user->name, + 'email' => $user->email, + 'send_invite' => 'true', ]); $this->assertDatabaseMissing('activities', ['type' => 'USER_CREATE']); @@ -286,9 +382,9 @@ public function test_return_to_form_with_warning_if_the_invitation_sending_fails }); $resp = $this->asAdmin()->post('/settings/users/create', [ - 'name' => $user->name, - 'email' => $user->email, - 'send_invite' => 'true', + 'name' => $user->name, + 'email' => $user->email, + 'send_invite' => 'true', ]); $resp->assertRedirect('/settings/users/create'); @@ -314,8 +410,8 @@ public function test_user_create_update_fails_if_locale_is_invalid() // Both on create $resp = $this->post('/settings/users/create', [ 'language' => 'en 'My name', - 'email' => 'jimmy@example.com', + 'name' => 'My name', + 'email' => 'jimmy@example.com', ]); $resp->assertSessionHasErrors(['language' => 'The language may not be greater than 15 characters.']); $resp->assertSessionHasErrors(['language' => 'The language may only contain letters, numbers, dashes and underscores.']); From efff8700d47f83ab9c4c83b27143d9a27dc69c9a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Oct 2025 19:52:15 +0100 Subject: [PATCH 004/398] DB: Addressed test issues for user ID changes Reverted change for activities table so that a record is retained of past activity, and added a check where the ID may be displayed to ensure it does not mislead and accidentially reference other, newer users. --- app/Users/UserRepo.php | 1 - ...025_10_18_163331_clean_user_id_references.php | 4 ---- resources/views/settings/audit.blade.php | 6 +++++- .../views/settings/parts/table-user.blade.php | 15 +++++---------- .../parts/recycle-bin-list-item.blade.php | 9 ++++++++- tests/Activity/AuditLogTest.php | 16 ++++++++++++++++ tests/User/UserManagementTest.php | 5 +++-- 7 files changed, 37 insertions(+), 19 deletions(-) diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php index 7a4fa5f98d2..894d7c01f7d 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -198,7 +198,6 @@ protected function removeUserDependantRelations(User $user): void protected function nullifyUserNonDependantRelations(User $user): void { $toNullify = [ - 'activities' => ['user_id'], 'attachments' => ['created_by', 'updated_by'], 'comments' => ['created_by', 'updated_by'], 'deletions' => ['deleted_by'], diff --git a/database/migrations/2025_10_18_163331_clean_user_id_references.php b/database/migrations/2025_10_18_163331_clean_user_id_references.php index 75ff6af33cb..42e67013900 100644 --- a/database/migrations/2025_10_18_163331_clean_user_id_references.php +++ b/database/migrations/2025_10_18_163331_clean_user_id_references.php @@ -6,7 +6,6 @@ return new class extends Migration { protected static array $toNullify = [ - 'activities' => ['user_id'], 'attachments' => ['created_by', 'updated_by'], 'comments' => ['created_by', 'updated_by'], 'deletions' => ['deleted_by'], @@ -55,9 +54,6 @@ public function up(): void DB::table($tableName)->whereNotIn($columnName, $idSelectQuery)->delete(); } } - - // TODO - Ensure each is fully handled in user delete - // Start by writing tests for each of these columns } /** diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 8e477668040..0407275e059 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -88,7 +88,11 @@ class="text-item">{{ $type }} @foreach($activities as $activity)
- @include('settings.parts.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id]) + @if($activity->user && $activity->user->created_at <= $activity->created_at) + @include('settings.parts.table-user', ['user' => $activity->user]) + @else + [ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }} + @endif
{{ trans('settings.audit_table_event') }} diff --git a/resources/views/settings/parts/table-user.blade.php b/resources/views/settings/parts/table-user.blade.php index d29ad1979a0..affc7b6c4c4 100644 --- a/resources/views/settings/parts/table-user.blade.php +++ b/resources/views/settings/parts/table-user.blade.php @@ -1,12 +1,7 @@ {{-- -$user - User mode to display, Can be null. -$user_id - Id of user to show. Must be provided. +$user - User to display. --}} -@if($user) - -
{{ $user->name }}
-
{{ $user->name }}
-
-@else - [ID: {{ $user_id }}] {{ trans('common.deleted_user') }} -@endif \ No newline at end of file + +
{{ $user->name }}
+
{{ $user->name }}
+
\ No newline at end of file diff --git a/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php b/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php index b18f9cbe08c..2dad617dcad 100644 --- a/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php +++ b/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php @@ -33,7 +33,14 @@ @endif
-
{{ trans('settings.recycle_bin_deleted_by') }}:
@include('settings.parts.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])
+
+ {{ trans('settings.recycle_bin_deleted_by') }}:
+ @if($deletion->deleter) + @include('settings.parts.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by]) + @else + {{ trans('common.deleted_user') }} + @endif +
{{ trans('settings.recycle_bin_deleted_at') }}:
{{ $deletion->created_at }}
diff --git a/tests/Activity/AuditLogTest.php b/tests/Activity/AuditLogTest.php index 6b435544df7..a6ba6be9f62 100644 --- a/tests/Activity/AuditLogTest.php +++ b/tests/Activity/AuditLogTest.php @@ -83,6 +83,22 @@ public function test_shows_activity_for_deleted_users() $resp->assertSeeText("[ID: {$viewer->id}] Deleted User"); } + public function test_deleted_user_shows_if_user_created_date_is_later_than_activity() + { + $viewer = $this->users->viewer(); + $this->actingAs($viewer); + $page = $this->entities->page(); + $this->activityService->add(ActivityType::PAGE_CREATE, $page); + $viewer->created_at = Carbon::now()->addDay(); + $viewer->save(); + + $this->actingAs($this->users->admin()); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText("[ID: {$viewer->id}] Deleted User"); + $resp->assertDontSee($viewer->name); + } + public function test_filters_by_key() { $this->actingAs($this->users->admin()); diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index a34a9e7f4c4..d50ac208791 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -221,7 +221,6 @@ public function test_delete_with_empty_owner_migration_id_clears_relevant_id_use $watch = Watch::factory()->create(['user_id' => $user->id]); $userColumnsByTable = [ - 'activities' => ['user_id'], 'api_tokens' => ['user_id'], 'attachments' => ['created_by', 'updated_by'], 'comments' => ['created_by', 'updated_by'], @@ -259,7 +258,6 @@ public function test_delete_with_empty_owner_migration_id_clears_relevant_id_use } // Check models exist where should be retained - $this->assertDatabaseHas('activities', ['id' => $activity->id, 'user_id' => null]); $this->assertDatabaseHas('attachments', ['id' => $attachment->id, 'created_by' => null, 'updated_by' => null]); $this->assertDatabaseHas('comments', ['id' => $comment->id, 'created_by' => null, 'updated_by' => null]); $this->assertDatabaseHas('deletions', ['id' => $deletion->id, 'deleted_by' => null]); @@ -276,6 +274,9 @@ public function test_delete_with_empty_owner_migration_id_clears_relevant_id_use $this->assertDatabaseMissing('social_accounts', ['id' => $socialAccount->id]); $this->assertDatabaseMissing('user_invites', ['token' => 'abc123']); $this->assertDatabaseMissing('watches', ['id' => $watch->id]); + + // Ensure activity remains using the old ID (Special case for auditing changes) + $this->assertDatabaseHas('activities', ['id' => $activity->id, 'user_id' => $user->id]); } public function test_delete_removes_user_preferences() From ea6344898f435c54e225926a68d0b9a26697acd1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 21 Oct 2025 14:12:55 +0100 Subject: [PATCH 005/398] Images: Added nulling of image page relation on page delete --- app/Entities/Tools/TrashCan.php | 18 ++++++++--------- ..._134751_update_entity_relation_columns.php | 10 ++++++++++ tests/Entity/PageTest.php | 20 +++++++++++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index cc43b909625..c298169c383 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -15,6 +15,7 @@ use BookStack\Exceptions\NotifyException; use BookStack\Facades\Activity; use BookStack\Uploads\AttachmentService; +use BookStack\Uploads\Image; use BookStack\Uploads\ImageService; use BookStack\Util\DatabaseTransaction; use Exception; @@ -217,14 +218,11 @@ protected function destroyPage(Page $page): int ->where('default_template_id', '=', $page->id) ->update(['default_template_id' => null]); - // TODO - Handle related images (uploaded_to for gallery/drawings). - // Should maybe reset to null - // But does that present visibility/permission issues if they used to retain their old - // unused ID? - // If so, might be better to leave them as-is like before, but ensure the maintenance - // cleanup command/action can find these "orphaned" images and delete them. - // But that would leave potential attachment to new pages on increment reset scenarios. - // Need to review permission scenarios for null field values relative to storage options. + // Nullify uploaded image relations + Image::query() + ->whereIn('type', ['gallery', 'drawio']) + ->where('uploaded_to', '=', $page->id) + ->update(['uploaded_to' => null]); $page->forceDelete(); @@ -275,8 +273,8 @@ public function destroyFromDeletion(Deletion $deletion): int // exists in the event it has already been destroyed during this request. $entity = $deletion->deletable()->first(); $count = 0; - if ($entity) { - $count = $this->destroyEntity($deletion->deletable); + if ($entity instanceof Entity) { + $count = $this->destroyEntity($entity); } $deletion->delete(); diff --git a/database/migrations/2025_09_15_134751_update_entity_relation_columns.php b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php index 6fbeb1dd111..f8622d7c682 100644 --- a/database/migrations/2025_09_15_134751_update_entity_relation_columns.php +++ b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php @@ -2,6 +2,7 @@ use BookStack\Permissions\JointPermissionBuilder; use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Query\Builder; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; @@ -66,6 +67,15 @@ public function up(): void DB::table('images')->where('uploaded_to', '=', 0)->update(['uploaded_to' => null]); DB::table('activities')->where('loggable_id', '=', 0)->update(['loggable_id' => null]); + // Clean up any orphaned gallery/drawio images to nullify their page relation + DB::table('images') + ->whereIn('type', ['gallery', 'drawio']) + ->whereNotIn('uploaded_to', function (Builder $query) { + $query->select('id') + ->from('entities') + ->where('type', '=', 'page'); + })->update(['uploaded_to' => null]); + // Rebuild joint permissions if needed // This was moved here from 2023_01_24_104625_refactor_joint_permissions_storage since the changes // made for this release would mean our current logic would not be compatible with diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index 6994144626e..b9e1294e0ec 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -4,6 +4,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Page; +use BookStack\Uploads\Image; use Carbon\Carbon; use Tests\TestCase; @@ -158,6 +159,25 @@ public function test_page_full_delete_removes_all_revisions() ]); } + public function test_page_full_delete_nulls_related_images() + { + $page = $this->entities->page(); + $image = Image::factory()->create(['type' => 'gallery', 'uploaded_to' => $page->id]); + + $this->asEditor()->delete($page->getUrl()); + $this->asAdmin()->post('/settings/recycle-bin/empty'); + + $this->assertDatabaseMissing('images', [ + 'type' => 'gallery', + 'uploaded_to' => $page->id, + ]); + + $this->assertDatabaseHas('images', [ + 'id' => $image->id, + 'uploaded_to' => null, + ]); + } + public function test_page_copy() { $page = $this->entities->page(); From 2c81447c9e41b9a50e147a312746f838285ec1a3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 21 Oct 2025 14:47:24 +0100 Subject: [PATCH 006/398] Deps: Updated PHP deps via composer --- composer.lock | 154 +++++++++++++++++++++++++------------------------- 1 file changed, 78 insertions(+), 76 deletions(-) diff --git a/composer.lock b/composer.lock index e6306263362..2fbad908a1d 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.356.32", + "version": "3.356.42", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "c564fb87e180da5ae45185a4526bb05a69998d05" + "reference": "c886d60206d2f100c2d132052875f53ce3f999f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c564fb87e180da5ae45185a4526bb05a69998d05", - "reference": "c564fb87e180da5ae45185a4526bb05a69998d05", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c886d60206d2f100c2d132052875f53ce3f999f7", + "reference": "c886d60206d2f100c2d132052875f53ce3f999f7", "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.356.32" + "source": "https://github.com/aws/aws-sdk-php/tree/3.356.42" }, - "time": "2025-10-03T18:12:05+00:00" + "time": "2025-10-17T18:17:41+00:00" }, { "name": "bacon/bacon-qr-code", @@ -634,16 +634,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.1.2", + "version": "v3.1.3", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "b3493e35d31a5e76ec24c3b64a29b0034b2f32a6" + "reference": "baed300e4fb8226359c04395518059a136e2a2e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/b3493e35d31a5e76ec24c3b64a29b0034b2f32a6", - "reference": "b3493e35d31a5e76ec24c3b64a29b0034b2f32a6", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/baed300e4fb8226359c04395518059a136e2a2e2", + "reference": "baed300e4fb8226359c04395518059a136e2a2e2", "shasum": "" }, "require": { @@ -692,9 +692,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.2" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.3" }, - "time": "2025-09-23T03:06:41+00:00" + "time": "2025-10-14T13:10:17+00:00" }, { "name": "dompdf/php-font-lib", @@ -1739,16 +1739,16 @@ }, { "name": "laravel/framework", - "version": "v12.32.5", + "version": "v12.34.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "77b2740391cd2a825ba59d6fada45e9b8b9bcc5a" + "reference": "f9ec5a5d88bc8c468f17b59f88e05c8ac3c8d687" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/77b2740391cd2a825ba59d6fada45e9b8b9bcc5a", - "reference": "77b2740391cd2a825ba59d6fada45e9b8b9bcc5a", + "url": "https://api.github.com/repos/laravel/framework/zipball/f9ec5a5d88bc8c468f17b59f88e05c8ac3c8d687", + "reference": "f9ec5a5d88bc8c468f17b59f88e05c8ac3c8d687", "shasum": "" }, "require": { @@ -1860,7 +1860,7 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.6.5", + "orchestra/testbench-core": "^10.7.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -1954,7 +1954,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-09-30T17:39:22+00:00" + "time": "2025-10-14T13:58:31+00:00" }, { "name": "laravel/prompts", @@ -2017,16 +2017,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.5", + "version": "v2.0.6", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "3832547db6e0e2f8bb03d4093857b378c66eceed" + "reference": "038ce42edee619599a1debb7e81d7b3759492819" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/3832547db6e0e2f8bb03d4093857b378c66eceed", - "reference": "3832547db6e0e2f8bb03d4093857b378c66eceed", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/038ce42edee619599a1debb7e81d7b3759492819", + "reference": "038ce42edee619599a1debb7e81d7b3759492819", "shasum": "" }, "require": { @@ -2074,7 +2074,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-09-22T17:29:40+00:00" + "time": "2025-10-09T13:42:30+00:00" }, { "name": "laravel/socialite", @@ -2405,16 +2405,16 @@ }, { "name": "league/flysystem", - "version": "3.30.0", + "version": "3.30.1", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" + "reference": "c139fd65c1f796b926f4aec0df37f6caa959a8da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", - "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/c139fd65c1f796b926f4aec0df37f6caa959a8da", + "reference": "c139fd65c1f796b926f4aec0df37f6caa959a8da", "shasum": "" }, "require": { @@ -2482,22 +2482,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.30.1" }, - "time": "2025-06-25T13:29:59+00:00" + "time": "2025-10-20T15:35:26+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.29.0", + "version": "3.30.1", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9" + "reference": "d286e896083bed3190574b8b088b557b59eb66f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/c6ff6d4606e48249b63f269eba7fabdb584e76a9", - "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/d286e896083bed3190574b8b088b557b59eb66f5", + "reference": "d286e896083bed3190574b8b088b557b59eb66f5", "shasum": "" }, "require": { @@ -2537,9 +2537,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.29.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.30.1" }, - "time": "2024-08-17T13:10:48+00:00" + "time": "2025-10-20T15:27:33+00:00" }, { "name": "league/flysystem-local", @@ -3602,31 +3602,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.1", + "version": "v2.3.2", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" + "reference": "eb61920a53057a7debd718a5b89c2178032b52c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", - "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/eb61920a53057a7debd718a5b89c2178032b52c0", + "reference": "eb61920a53057a7debd718a5b89c2178032b52c0", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.2.6" + "symfony/console": "^7.3.4" }, "require-dev": { - "illuminate/console": "^11.44.7", - "laravel/pint": "^1.22.0", + "illuminate/console": "^11.46.1", + "laravel/pint": "^1.25.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.2", - "phpstan/phpstan": "^1.12.25", + "pestphp/pest": "^2.36.0 || ^3.8.4", + "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.2.6", + "symfony/var-dumper": "^7.3.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3669,7 +3669,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.2" }, "funding": [ { @@ -3685,7 +3685,7 @@ "type": "github" } ], - "time": "2025-05-08T08:14:37+00:00" + "time": "2025-10-18T11:10:27+00:00" }, { "name": "onelogin/php-saml", @@ -3947,16 +3947,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.46", + "version": "3.0.47", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6" + "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", - "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/9d6ca36a6c2dd434765b1071b2644a1c683b385d", + "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d", "shasum": "" }, "require": { @@ -4037,7 +4037,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.46" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.47" }, "funding": [ { @@ -4053,7 +4053,7 @@ "type": "tidelift" } ], - "time": "2025-06-26T16:29:55+00:00" + "time": "2025-10-06T01:07:24+00:00" }, { "name": "pragmarx/google2fa", @@ -4584,16 +4584,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.12", + "version": "v0.12.13", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "cd23863404a40ccfaf733e3af4db2b459837f7e7" + "reference": "d86c2f750e72017a5cdb1b9f1cef468a5cbacd1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/cd23863404a40ccfaf733e3af4db2b459837f7e7", - "reference": "cd23863404a40ccfaf733e3af4db2b459837f7e7", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/d86c2f750e72017a5cdb1b9f1cef468a5cbacd1e", + "reference": "d86c2f750e72017a5cdb1b9f1cef468a5cbacd1e", "shasum": "" }, "require": { @@ -4608,9 +4608,11 @@ "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2" + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" }, "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", "ext-pdo-sqlite": "The doc command requires SQLite to work.", "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." @@ -4656,9 +4658,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.12" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.13" }, - "time": "2025-09-20T13:46:31+00:00" + "time": "2025-10-20T22:48:29+00:00" }, { "name": "ralouphie/getallheaders", @@ -8026,28 +8028,28 @@ }, { "name": "webmozart/assert", - "version": "1.11.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + "reference": "541057574806f942c94662b817a50f63f7345360" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/541057574806f942c94662b817a50f63f7345360", + "reference": "541057574806f942c94662b817a50f63f7345360", "shasum": "" }, "require": { "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", "php": "^7.2 || ^8.0" }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", "extra": { @@ -8078,9 +8080,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" + "source": "https://github.com/webmozarts/assert/tree/1.12.0" }, - "time": "2022-06-03T18:03:27+00:00" + "time": "2025-10-20T12:43:39+00:00" } ], "packages-dev": [ @@ -8837,11 +8839,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.30", + "version": "2.1.31", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a4a7f159927983dd4f7c8020ed227d80b7f39d7d", - "reference": "a4a7f159927983dd4f7c8020ed227d80b7f39d7d", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ead89849d879fe203ce9292c6ef5e7e76f867b96", + "reference": "ead89849d879fe203ce9292c6ef5e7e76f867b96", "shasum": "" }, "require": { @@ -8886,7 +8888,7 @@ "type": "github" } ], - "time": "2025-10-02T16:07:52+00:00" + "time": "2025-10-10T14:14:11+00:00" }, { "name": "phpunit/php-code-coverage", From ebf82617b870625a77a4ce50841be22b84c8428f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 21 Oct 2025 18:34:21 +0100 Subject: [PATCH 007/398] Code: Added groovy syntax highlighting For #5822 --- resources/js/code/languages.js | 1 + resources/js/code/legacy-modes.mjs | 2 ++ resources/views/pages/parts/code-editor.blade.php | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/js/code/languages.js b/resources/js/code/languages.js index da9631a138a..52fc55dbf0a 100644 --- a/resources/js/code/languages.js +++ b/resources/js/code/languages.js @@ -33,6 +33,7 @@ const modeMap = { 'f#': () => legacyLoad('fSharp'), fsharp: () => legacyLoad('fSharp'), go: () => legacyLoad('go'), + groovy: () => legacyLoad('groovy'), haskell: () => legacyLoad('haskell'), hs: () => legacyLoad('haskell'), html: async () => html({selfClosingTags: true}), diff --git a/resources/js/code/legacy-modes.mjs b/resources/js/code/legacy-modes.mjs index 7b70bec10a1..a5878ccab20 100644 --- a/resources/js/code/legacy-modes.mjs +++ b/resources/js/code/legacy-modes.mjs @@ -5,6 +5,8 @@ export {clojure} from '@codemirror/legacy-modes/mode/clojure'; export {diff} from '@codemirror/legacy-modes/mode/diff'; export {fortran} from '@codemirror/legacy-modes/mode/fortran'; export {go} from '@codemirror/legacy-modes/mode/go'; +export {groovy} from '@codemirror/legacy-modes/mode/groovy'; +export {haxe} from '@codemirror/legacy-modes/mode/haxe'; export {haskell} from '@codemirror/legacy-modes/mode/haskell'; export {julia} from '@codemirror/legacy-modes/mode/julia'; export {lua} from '@codemirror/legacy-modes/mode/lua'; diff --git a/resources/views/pages/parts/code-editor.blade.php b/resources/views/pages/parts/code-editor.blade.php index 303b3b0bee3..8fa43373859 100644 --- a/resources/views/pages/parts/code-editor.blade.php +++ b/resources/views/pages/parts/code-editor.blade.php @@ -23,7 +23,7 @@ class="popup-background code-editor">
@php $languages = [ - 'Bash', 'CSS', 'C', 'C++', 'C#', 'Clojure', 'Dart', 'Diff', 'Fortran', 'F#', 'Go', 'Haskell', 'HTML', 'INI', + 'Bash', 'CSS', 'C', 'C++', 'C#', 'Clojure', 'Dart', 'Diff', 'Fortran', 'F#', 'Go', 'Groovy', 'Haskell', 'HTML', 'INI', 'Java', 'JavaScript', 'JSON', 'Julia', 'Kotlin', 'LaTeX', 'Lua', 'MarkDown', 'MATLAB', 'MSSQL', 'MySQL', 'Nginx', 'OCaml', 'Octave', 'Pascal', 'Perl', 'PHP', 'PL/SQL', 'PostgreSQL', 'Powershell', 'Python', 'R', 'Ruby', 'Rust', 'SAS', 'Scala', 'Scheme', 'Shell', 'Smarty', 'SQL', 'SQLite', 'Swift', From abe9c1e5a3c624b17e7de52e119544ea0b37f499 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Oct 2025 14:29:02 +0100 Subject: [PATCH 008/398] API Docs: Updated link to archived GitHub repo Closes #5813 --- resources/views/api-docs/parts/getting-started.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index 229fe7dce74..663389047ce 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 GitHub.

Some alternative options for extension and customization can be found below: From 082dbc9944e6ee1f08b16ff445b48498edfdcc1f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Oct 2025 14:58:29 +0100 Subject: [PATCH 009/398] API: Started building comments API endpoints --- app/Activity/CommentRepo.php | 10 ++++- .../Controllers/CommentApiController.php | 43 +++++++++++++++++++ app/Activity/Models/Comment.php | 31 ++++++++++--- app/Entities/Models/Entity.php | 2 +- ...7_update_comments_relation_field_names.php | 30 +++++++++++++ routes/api.php | 6 ++- 6 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 app/Activity/Controllers/CommentApiController.php create mode 100644 database/migrations/2025_10_22_134507_update_comments_relation_field_names.php diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 7005f8fcf83..1c2333cae83 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -5,9 +5,9 @@ use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Entity; use BookStack\Exceptions\NotifyException; -use BookStack\Exceptions\PrettyException; use BookStack\Facades\Activity as ActivityService; use BookStack\Util\HtmlDescriptionFilter; +use Illuminate\Database\Eloquent\Builder; class CommentRepo { @@ -19,6 +19,14 @@ public function getById(int $id): Comment return Comment::query()->findOrFail($id); } + /** + * Start a query for comments visible to the user. + */ + public function getQueryForVisible(): Builder + { + return Comment::query()->scopes('visible'); + } + /** * Create a new comment on an entity. */ diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php new file mode 100644 index 00000000000..25421077ab4 --- /dev/null +++ b/app/Activity/Controllers/CommentApiController.php @@ -0,0 +1,43 @@ +commentRepo->getQueryForVisible(); + + return $this->apiListingResponse($query, [ + 'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at' + ]); + } +} diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 0cb83d61e8d..caca7809f5a 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -3,12 +3,15 @@ namespace BookStack\Activity\Models; use BookStack\App\Model; +use BookStack\Permissions\Models\JointPermission; +use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\OwnableInterface; -use BookStack\Users\Models\User; use BookStack\Util\HtmlContentFilter; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; /** @@ -17,8 +20,8 @@ * @property string $html * @property int|null $parent_id - Relates to local_id, not id * @property int $local_id - * @property string $entity_type - * @property int $entity_id + * @property string $commentable_type + * @property int $commentable_id * @property string $content_ref * @property bool $archived */ @@ -44,8 +47,8 @@ public function entity(): MorphTo public function parent(): BelongsTo { return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent') - ->where('entity_type', '=', $this->entity_type) - ->where('entity_id', '=', $this->entity_id); + ->where('commentable_type', '=', $this->commentable_type) + ->where('commentable_id', '=', $this->commentable_id); } /** @@ -58,11 +61,27 @@ public function isUpdated(): bool public function logDescriptor(): string { - return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})"; + return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})"; } public function safeHtml(): string { return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? ''); } + + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id') + ->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type'); + } + + /** + * Scope the query to just the comments visible to the user based upon the + * user visibility of what has been commented on. + */ + public function scopeVisible(Builder $query): Builder + { + return app()->make(PermissionApplicator::class) + ->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type'); + } } diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index b71016ea1c1..c6839c15aa0 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -240,7 +240,7 @@ public function tags(): MorphMany */ public function comments(bool $orderByCreated = true): MorphMany { - $query = $this->morphMany(Comment::class, 'entity'); + $query = $this->morphMany(Comment::class, 'commentable'); return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query; } diff --git a/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php b/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php new file mode 100644 index 00000000000..de13453def3 --- /dev/null +++ b/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php @@ -0,0 +1,30 @@ +renameColumn('entity_id', 'commentable_id'); + $table->renameColumn('entity_type', 'commentable_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('comments', function (Blueprint $table) { + $table->renameColumn('commentable_id', 'entity_id'); + $table->renameColumn('commentable_type', 'entity_type'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 99df24aed0a..b030ca7f73b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,7 +6,7 @@ * Controllers all end with "ApiController" */ -use BookStack\Activity\Controllers\AuditLogApiController; +use BookStack\Activity\Controllers as ActivityControllers; use BookStack\Api\ApiDocsController; use BookStack\App\SystemApiController; use BookStack\Entities\Controllers as EntityControllers; @@ -70,6 +70,8 @@ Route::get('search', [SearchApiController::class, 'all']); +Route::get('comments', [ActivityControllers\CommentApiController::class, 'list']); + Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']); Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']); Route::get('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'read']); @@ -101,6 +103,6 @@ Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']); Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']); -Route::get('audit-log', [AuditLogApiController::class, 'list']); +Route::get('audit-log', [ActivityControllers\AuditLogApiController::class, 'list']); Route::get('system', [SystemApiController::class, 'read']); From 3ad1e31fcccd38bf864d54f4f813c81fb4574c4c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Oct 2025 18:44:49 +0100 Subject: [PATCH 010/398] API: Added comment-read endpoint, added api docs section descriptions --- .../Controllers/CommentApiController.php | 39 ++++++++++++++++--- app/Activity/Models/Comment.php | 6 ++- app/Api/ApiDocsGenerator.php | 24 ++++++++++-- .../Controllers/BookApiController.php | 2 +- resources/views/api-docs/index.blade.php | 4 +- routes/api.php | 4 ++ 6 files changed, 67 insertions(+), 12 deletions(-) diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 25421077ab4..3a4c33cd6c7 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -5,30 +5,31 @@ namespace BookStack\Activity\Controllers; use BookStack\Activity\CommentRepo; +use BookStack\Activity\Models\Comment; use BookStack\Http\ApiController; use Illuminate\Http\JsonResponse; +/** + * The comment data model has a 'local_id' property, which is a unique integer ID + * scoped to the page which the comment is on. The 'parent_id' is used for replies + * and refers to the 'local_id' of the parent comment on the same page, not the main + * globally unique 'id'. + */ class CommentApiController extends ApiController { // TODO - Add tree-style comment listing to page-show responses. - // TODO - list // TODO - create - // TODO - read // TODO - update // TODO - delete // TODO - Test visibility controls // TODO - Test permissions of each action - // TODO - Support intro block for API docs so we can explain the - // properties for comments in a shared kind of way? - public function __construct( protected CommentRepo $commentRepo, ) { } - /** * Get a listing of comments visible to the user. */ @@ -40,4 +41,30 @@ public function list(): JsonResponse 'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at' ]); } + + /** + * Read the details of a single comment, along with its direct replies. + */ + public function read(string $id): JsonResponse + { + $comment = $this->commentRepo->getQueryForVisible() + ->where('id', '=', $id)->firstOrFail(); + + $replies = $this->commentRepo->getQueryForVisible() + ->where('parent_id', '=', $comment->local_id) + ->where('commentable_id', '=', $comment->commentable_id) + ->where('commentable_type', '=', $comment->commentable_type) + ->get(); + + /** @var Comment[] $toProcess */ + $toProcess = [$comment, ...$replies]; + foreach ($toProcess as $commentToProcess) { + $commentToProcess->setAttribute('html', $commentToProcess->safeHtml()); + $commentToProcess->makeVisible('html'); + } + + $comment->setRelation('replies', $replies); + + return response()->json($comment); + } } diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index caca7809f5a..91c91e7a8ae 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -16,7 +16,6 @@ /** * @property int $id - * @property string $text - Deprecated & now unused (#4821) * @property string $html * @property int|null $parent_id - Relates to local_id, not id * @property int $local_id @@ -31,6 +30,11 @@ class Comment extends Model implements Loggable, OwnableInterface use HasCreatorAndUpdater; protected $fillable = ['parent_id']; + protected $hidden = ['html']; + + protected $casts = [ + 'archived' => 'boolean', + ]; /** * Get the entity that this comment belongs to. diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index 287c8387790..eb8f5508c70 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -83,11 +83,19 @@ protected function loadDetailsFromFiles(Collection $routes): Collection protected function loadDetailsFromControllers(Collection $routes): Collection { return $routes->map(function (array $route) { + $class = $this->getReflectionClass($route['controller']); $method = $this->getReflectionMethod($route['controller'], $route['controller_method']); $comment = $method->getDocComment(); - $route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null; + $route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null; $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']); + // Load class description for the model + // Not ideal to have it here on each route, but adding it in a more structured manner would break + // docs resulting JSON format and therefore be an API break. + // Save refactoring for a more significant set of changes. + $classComment = $class->getDocComment(); + $route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null; + return $route; }); } @@ -140,7 +148,7 @@ protected function getValidationAsString($validation): string /** * Parse out the description text from a class method comment. */ - protected function parseDescriptionFromMethodComment(string $comment): string + protected function parseDescriptionFromDocBlockComment(string $comment): string { $matches = []; preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches); @@ -155,6 +163,16 @@ protected function parseDescriptionFromMethodComment(string $comment): string * @throws ReflectionException */ protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod + { + return $this->getReflectionClass($className)->getMethod($methodName); + } + + /** + * Get a reflection class from the given class name. + * + * @throws ReflectionException + */ + protected function getReflectionClass(string $className): ReflectionClass { $class = $this->reflectionClasses[$className] ?? null; if ($class === null) { @@ -162,7 +180,7 @@ protected function getReflectionMethod(string $className, string $methodName): R $this->reflectionClasses[$className] = $class; } - return $class->getMethod($methodName); + return $class; } /** diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index 807f5a69c8f..325f0583c67 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -58,7 +58,7 @@ public function create(Request $request) /** * View the details of a single book. - * The response data will contain 'content' property listing the chapter and pages directly within, in + * The response data will contain a 'content' property listing the chapter and pages directly within, in * the same structure as you'd see within the BookStack interface when viewing a book. Top-level * contents will have a 'type' property to distinguish between pages & chapters. */ diff --git a/resources/views/api-docs/index.blade.php b/resources/views/api-docs/index.blade.php index 9345a7bcead..84c6d21acfb 100644 --- a/resources/views/api-docs/index.blade.php +++ b/resources/views/api-docs/index.blade.php @@ -45,7 +45,9 @@ @foreach($docs as $model => $endpoints)

{{ $model }}

- + @if($endpoints[0]['model_description']) +

{{ $endpoints[0]['model_description'] }}

+ @endif @foreach($endpoints as $endpoint) @include('api-docs.parts.endpoint', ['endpoint' => $endpoint, 'loop' => $loop]) @endforeach diff --git a/routes/api.php b/routes/api.php index b030ca7f73b..4b661da5d23 100644 --- a/routes/api.php +++ b/routes/api.php @@ -71,6 +71,10 @@ Route::get('search', [SearchApiController::class, 'all']); Route::get('comments', [ActivityControllers\CommentApiController::class, 'list']); +Route::post('comments', [ActivityControllers\CommentApiController::class, 'create']); +Route::get('comments/{id}', [ActivityControllers\CommentApiController::class, 'read']); +Route::put('comments/{id}', [ActivityControllers\CommentApiController::class, 'update']); +Route::delete('comments/{id}', [ActivityControllers\CommentApiController::class, 'delete']); Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']); Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']); From cbf27d70c823c696a831f1945e4a7b869af8ce65 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 23 Oct 2025 10:21:33 +0100 Subject: [PATCH 011/398] API: Added comment CUD endpoints, drafted tests Move some checks and made some tweaks to the repo to support consistency between API and UI. --- app/Activity/CommentRepo.php | 39 ++++++++- .../Controllers/CommentApiController.php | 87 +++++++++++++++++-- .../Controllers/CommentController.php | 7 +- app/Http/ApiController.php | 6 ++ app/Permissions/Permission.php | 2 - tests/Activity/CommentsApiTest.php | 43 +++++++++ tests/Api/BooksApiTest.php | 1 - 7 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 tests/Activity/CommentsApiTest.php diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 1c2333cae83..ba12f4d33cb 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -4,6 +4,7 @@ use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; use BookStack\Exceptions\NotifyException; use BookStack\Facades\Activity as ActivityService; use BookStack\Util\HtmlDescriptionFilter; @@ -19,6 +20,15 @@ public function getById(int $id): Comment return Comment::query()->findOrFail($id); } + /** + * Get a comment by ID, ensuring it is visible to the user based upon access to the page + * which the comment is attached to. + */ + public function getVisibleById(int $id): Comment + { + return $this->getQueryForVisible()->findOrFail($id); + } + /** * Start a query for comments visible to the user. */ @@ -32,6 +42,23 @@ public function getQueryForVisible(): Builder */ public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment { + // Prevent comments being added to draft pages + if ($entity instanceof Page && $entity->draft) { + throw new \Exception(trans('errors.cannot_add_comment_to_draft')); + } + + // Validate parent ID + if ($parentId !== null) { + $parentCommentExists = Comment::query() + ->where('entity_id', '=', $entity->id) + ->where('entity_type', '=', $entity->getMorphClass()) + ->where('local_id', '=', $parentId) + ->exists(); + if (!$parentCommentExists) { + $parentId = null; + } + } + $userId = user()->id; $comment = new Comment(); @@ -67,7 +94,7 @@ public function update(Comment $comment, string $html): Comment /** * Archive an existing comment. */ - public function archive(Comment $comment): Comment + public function archive(Comment $comment, bool $log = true): Comment { if ($comment->parent_id) { throw new NotifyException('Only top-level comments can be archived.', '/', 400); @@ -76,7 +103,9 @@ public function archive(Comment $comment): Comment $comment->archived = true; $comment->save(); - ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + if ($log) { + ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + } return $comment; } @@ -84,7 +113,7 @@ public function archive(Comment $comment): Comment /** * Un-archive an existing comment. */ - public function unarchive(Comment $comment): Comment + public function unarchive(Comment $comment, bool $log = true): Comment { if ($comment->parent_id) { throw new NotifyException('Only top-level comments can be un-archived.', '/', 400); @@ -93,7 +122,9 @@ public function unarchive(Comment $comment): Comment $comment->archived = false; $comment->save(); - ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + if ($log) { + ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + } return $comment; } diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 3a4c33cd6c7..7ba9b5b645d 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -6,8 +6,12 @@ use BookStack\Activity\CommentRepo; use BookStack\Activity\Models\Comment; +use BookStack\Entities\Queries\PageQueries; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Http\Response; /** * The comment data model has a 'local_id' property, which is a unique integer ID @@ -18,15 +22,26 @@ class CommentApiController extends ApiController { // TODO - Add tree-style comment listing to page-show responses. - // TODO - create - // TODO - update - // TODO - delete // TODO - Test visibility controls // TODO - Test permissions of each action + protected array $rules = [ + 'create' => [ + 'page_id' => ['required', 'integer'], + 'reply_to' => ['nullable', 'integer'], + 'html' => ['required', 'string'], + 'content_ref' => ['string'], + ], + 'update' => [ + 'html' => ['required', 'string'], + 'archived' => ['boolean'], + ] + ]; + public function __construct( protected CommentRepo $commentRepo, + protected PageQueries $pageQueries, ) { } @@ -42,13 +57,34 @@ public function list(): JsonResponse ]); } + /** + * Create a new comment on a page. + * If commenting as a reply to an existing comment, the 'reply_to' parameter + * should be provided, set to the 'local_id' of the comment being replied to. + */ + public function create(Request $request): JsonResponse + { + $this->checkPermission(Permission::CommentCreateAll); + + $input = $this->validate($request, $this->rules()['create']); + $page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']); + + $comment = $this->commentRepo->create( + $page, + $input['html'], + $input['reply_to'] ?? null, + $input['content_ref'] ?? '', + ); + + return response()->json($comment); + } + /** * Read the details of a single comment, along with its direct replies. */ public function read(string $id): JsonResponse { - $comment = $this->commentRepo->getQueryForVisible() - ->where('id', '=', $id)->firstOrFail(); + $comment = $this->commentRepo->getVisibleById(intval($id)); $replies = $this->commentRepo->getQueryForVisible() ->where('parent_id', '=', $comment->local_id) @@ -67,4 +103,45 @@ public function read(string $id): JsonResponse return response()->json($comment); } + + + /** + * Update the content or archived status of an existing comment. + * + * Only provide a new archived status if needing to actively change the archive state. + * Only top-level comments (non-replies) can be archived or unarchived. + */ + public function update(Request $request, string $id): JsonResponse + { + $comment = $this->commentRepo->getVisibleById(intval($id)); + $this->checkOwnablePermission(Permission::CommentUpdate, $comment); + + $input = $this->validate($request, $this->rules()['update']); + + if (isset($input['archived'])) { + $archived = $input['archived']; + if ($archived) { + $this->commentRepo->archive($comment, false); + } else { + $this->commentRepo->unarchive($comment, false); + } + } + + $comment = $this->commentRepo->update($comment, $input['html']); + + return response()->json($comment); + } + + /** + * Delete a single comment from the system. + */ + public function delete(string $id): Response + { + $comment = $this->commentRepo->getVisibleById(intval($id)); + $this->checkOwnablePermission(Permission::CommentDelete, $comment); + + $this->commentRepo->delete($comment); + + return response('', 204); + } } diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index fd5463dff18..f61a2c8df6e 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -22,7 +22,7 @@ public function __construct( /** * Save a new comment for a Page. * - * @throws ValidationException + * @throws ValidationException|\Exception */ public function savePageComment(Request $request, int $pageId) { @@ -37,11 +37,6 @@ public function savePageComment(Request $request, int $pageId) return response('Not found', 404); } - // Prevent adding comments to draft pages - if ($page->draft) { - return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400); - } - // Create a new comment. $this->checkPermission(Permission::CommentCreateAll); $contentRef = $input['content_ref'] ?? ''; diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index 1a92afa3380..ac8844b811c 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -8,6 +8,12 @@ abstract class ApiController extends Controller { + /** + * The validation rules for this controller. + * Can alternative be defined in a rules() method is they need to be dynamic. + * + * @var array + */ protected array $rules = []; /** diff --git a/app/Permissions/Permission.php b/app/Permissions/Permission.php index a434e54fd61..04878ada01f 100644 --- a/app/Permissions/Permission.php +++ b/app/Permissions/Permission.php @@ -48,9 +48,7 @@ enum Permission: string case AttachmentUpdateAll = 'attachment-update-all'; case AttachmentUpdateOwn = 'attachment-update-own'; - case CommentCreate = 'comment-create'; case CommentCreateAll = 'comment-create-all'; - case CommentCreateOwn = 'comment-create-own'; case CommentDelete = 'comment-delete'; case CommentDeleteAll = 'comment-delete-all'; case CommentDeleteOwn = 'comment-delete-own'; diff --git a/tests/Activity/CommentsApiTest.php b/tests/Activity/CommentsApiTest.php new file mode 100644 index 00000000000..29769a2605b --- /dev/null +++ b/tests/Activity/CommentsApiTest.php @@ -0,0 +1,43 @@ + Date: Thu, 23 Oct 2025 16:52:29 +0100 Subject: [PATCH 012/398] API: Built out tests for comment API endpoints --- app/Activity/CommentRepo.php | 4 +- .../Controllers/CommentApiController.php | 18 +- app/Activity/Models/Comment.php | 2 +- tests/Activity/CommentsApiTest.php | 223 +++++++++++++++++- tests/TestCase.php | 2 +- 5 files changed, 228 insertions(+), 21 deletions(-) diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index ba12f4d33cb..20513bcd4f5 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -50,8 +50,8 @@ public function create(Entity $entity, string $html, ?int $parentId, string $con // Validate parent ID if ($parentId !== null) { $parentCommentExists = Comment::query() - ->where('entity_id', '=', $entity->id) - ->where('entity_type', '=', $entity->getMorphClass()) + ->where('commentable_id', '=', $entity->id) + ->where('commentable_type', '=', $entity->getMorphClass()) ->where('local_id', '=', $parentId) ->exists(); if (!$parentCommentExists) { diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 7ba9b5b645d..92551bf36a1 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -23,9 +23,6 @@ class CommentApiController extends ApiController { // TODO - Add tree-style comment listing to page-show responses. - // TODO - Test visibility controls - // TODO - Test permissions of each action - protected array $rules = [ 'create' => [ 'page_id' => ['required', 'integer'], @@ -34,7 +31,7 @@ class CommentApiController extends ApiController 'content_ref' => ['string'], ], 'update' => [ - 'html' => ['required', 'string'], + 'html' => ['string'], 'archived' => ['boolean'], ] ]; @@ -85,6 +82,7 @@ public function create(Request $request): JsonResponse public function read(string $id): JsonResponse { $comment = $this->commentRepo->getVisibleById(intval($id)); + $comment->load('createdBy', 'updatedBy'); $replies = $this->commentRepo->getQueryForVisible() ->where('parent_id', '=', $comment->local_id) @@ -117,17 +115,19 @@ public function update(Request $request, string $id): JsonResponse $this->checkOwnablePermission(Permission::CommentUpdate, $comment); $input = $this->validate($request, $this->rules()['update']); + $hasHtml = isset($input['html']); if (isset($input['archived'])) { - $archived = $input['archived']; - if ($archived) { - $this->commentRepo->archive($comment, false); + if ($input['archived']) { + $this->commentRepo->archive($comment, !$hasHtml); } else { - $this->commentRepo->unarchive($comment, false); + $this->commentRepo->unarchive($comment, !$hasHtml); } } - $comment = $this->commentRepo->update($comment, $input['html']); + if ($hasHtml) { + $comment = $this->commentRepo->update($comment, $input['html']); + } return response()->json($comment); } diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 91c91e7a8ae..4d6c7fa41c6 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -41,7 +41,7 @@ class Comment extends Model implements Loggable, OwnableInterface */ public function entity(): MorphTo { - return $this->morphTo('entity'); + return $this->morphTo('commentable'); } /** diff --git a/tests/Activity/CommentsApiTest.php b/tests/Activity/CommentsApiTest.php index 29769a2605b..ec4ddba995c 100644 --- a/tests/Activity/CommentsApiTest.php +++ b/tests/Activity/CommentsApiTest.php @@ -2,8 +2,8 @@ namespace Activity; -use BookStack\Activity\ActivityType; -use BookStack\Facades\Activity; +use BookStack\Activity\Models\Comment; +use BookStack\Permissions\Permission; use Tests\Api\TestsApi; use Tests\TestCase; @@ -13,31 +13,238 @@ class CommentsApiTest extends TestCase public function test_endpoint_permission_controls() { - // TODO + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll, Permission::CommentUpdateAll]); + + $page = $this->entities->page(); + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $this->actingAsForApi($user); + + $actions = [ + ['GET', '/api/comments'], + ['GET', "/api/comments/{$comment->id}"], + ['POST', "/api/comments"], + ['PUT', "/api/comments/{$comment->id}"], + ['DELETE', "/api/comments/{$comment->id}"], + ]; + + foreach ($actions as [$method, $endpoint]) { + $resp = $this->call($method, $endpoint); + $this->assertNotPermissionError($resp); + } + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $this->getJson("/api/comments")->assertSee(['id' => $comment->id]); + + $this->permissions->removeUserRolePermissions($user, [ + Permission::CommentDeleteAll, Permission::CommentDeleteOwn, + Permission::CommentUpdateAll, Permission::CommentUpdateOwn, + Permission::CommentCreateAll + ]); + + $this->assertPermissionError($this->json('delete', "/api/comments/{$comment->id}")); + $this->assertPermissionError($this->json('put', "/api/comments/{$comment->id}")); + $this->assertPermissionError($this->json('post', "/api/comments")); + $this->assertNotPermissionError($this->json('get', "/api/comments/{$comment->id}")); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->json('get', "/api/comments/{$comment->id}")->assertStatus(404); + $this->getJson("/api/comments")->assertDontSee(['id' => $comment->id]); } public function test_index() { - // TODO + $page = $this->entities->page(); + Comment::query()->delete(); + + $comments = Comment::factory()->count(10)->make(); + $page->comments()->saveMany($comments); + + $firstComment = $comments->first(); + $resp = $this->actingAsApiEditor()->getJson('/api/comments'); + $resp->assertJson([ + 'data' => [ + [ + 'id' => $firstComment->id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'parent_id' => null, + 'local_id' => $firstComment->local_id, + ], + ], + ]); + $resp->assertJsonCount(10, 'data'); + $resp->assertJson(['total' => 10]); + + $filtered = $this->getJson("/api/comments?filter[id]={$firstComment->id}"); + $filtered->assertJsonCount(1, 'data'); + $filtered->assertJson(['total' => 1]); } public function test_create() { - // TODO + $page = $this->entities->page(); + + $resp = $this->actingAsApiEditor()->postJson('/api/comments', [ + 'page_id' => $page->id, + 'html' => '

My wonderful comment

', + 'content_ref' => 'test-content-ref', + ]); + $resp->assertOk(); + $id = $resp->json('id'); + + $this->assertDatabaseHas('comments', [ + 'id' => $id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

My wonderful comment

', + ]); + + $comment = Comment::query()->findOrFail($id); + $this->assertIsInt($comment->local_id); + + $reply = $this->actingAsApiEditor()->postJson('/api/comments', [ + 'page_id' => $page->id, + 'html' => '

My wonderful reply

', + 'content_ref' => 'test-content-ref', + 'reply_to' => $comment->local_id, + ]); + $reply->assertOk(); + + $this->assertDatabaseHas('comments', [ + 'id' => $reply->json('id'), + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

My wonderful reply

', + 'parent_id' => $comment->local_id, + ]); } public function test_read() { - // TODO + $page = $this->entities->page(); + $user = $this->users->viewer(); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $user->id, + 'updated_by' => $user->id, + ]); + $page->comments()->save($comment); + $comment->refresh(); + $reply = Comment::factory()->make([ + 'parent_id' => $comment->local_id, + 'html' => '

A lovelyreply

', + ]); + $page->comments()->save($reply); + + $resp = $this->actingAsApiEditor()->getJson("/api/comments/{$comment->id}"); + $resp->assertJson([ + 'id' => $comment->id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

A lovely comment

', + 'archived' => false, + 'created_by' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'updated_by' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'replies' => [ + [ + 'id' => $reply->id, + 'html' => '

A lovelyreply

' + ] + ] + ]); } public function test_update() { - // TODO + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $this->users->viewer()->id, + 'updated_by' => $this->users->viewer()->id, + 'parent_id' => null, + ]); + $page->comments()->save($comment); + + $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [ + 'html' => '

A lovely updated comment

', + ])->assertOk(); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated comment

', + 'archived' => 0, + ]); + + $this->putJson("/api/comments/{$comment->id}", [ + 'archived' => true, + ]); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated comment

', + 'archived' => 1, + ]); + + $this->putJson("/api/comments/{$comment->id}", [ + 'archived' => false, + 'html' => '

A lovely updated again comment

', + ]); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated again comment

', + 'archived' => 0, + ]); + } + + public function test_update_cannot_archive_replies() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $this->users->viewer()->id, + 'updated_by' => $this->users->viewer()->id, + 'parent_id' => 90, + ]); + $page->comments()->save($comment); + + $resp = $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [ + 'archived' => true, + ]); + + $this->assertEquals($this->errorResponse('Only top-level comments can be archived.', 400), $resp->json()); + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'archived' => 0, + ]); } public function test_destroy() { - // TODO + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + ]); + $page->comments()->save($comment); + + $this->actingAsForApi($user)->deleteJson("/api/comments/{$comment->id}")->assertStatus(204); + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 2395317482e..f69f20d4c55 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -199,7 +199,7 @@ private function isPermissionError($response): bool { if ($response->status() === 403 && $response instanceof JsonResponse) { $errMessage = $response->getData(true)['error']['message'] ?? ''; - return $errMessage === 'You do not have permission to perform the requested action.'; + return str_contains($errMessage, 'do not have permission'); } return $response->status() === 302 From 4627dfd4f7ead66cba4ba2e042f0417c4a5db853 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 24 Oct 2025 10:18:52 +0100 Subject: [PATCH 013/398] API: Added comment tree to pages-read endpoint Includes tests to cover --- .../Controllers/CommentApiController.php | 5 ++-- app/Activity/Tools/CommentTree.php | 20 ++++++++++++-- .../Controllers/PageApiController.php | 18 ++++++++++--- .../Activity/Models/CommentFactory.php | 8 +++++- tests/Api/PagesApiTest.php | 26 +++++++++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 92551bf36a1..6c60de9da52 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -18,11 +18,12 @@ * scoped to the page which the comment is on. The 'parent_id' is used for replies * and refers to the 'local_id' of the parent comment on the same page, not the main * globally unique 'id'. + * + * If you want to get all comments for a page in a tree-like structure, as reflected in + * the UI, then that is provided on pages-read API responses. */ class CommentApiController extends ApiController { - // TODO - Add tree-style comment listing to page-show responses. - protected array $rules = [ 'create' => [ 'page_id' => ['required', 'integer'], diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php index 66df2943086..68f4a94d34d 100644 --- a/app/Activity/Tools/CommentTree.php +++ b/app/Activity/Tools/CommentTree.php @@ -13,6 +13,11 @@ class CommentTree * @var CommentTreeNode[] */ protected array $tree; + + /** + * A linear array of loaded comments. + * @var Comment[] + */ protected array $comments; public function __construct( @@ -39,7 +44,7 @@ public function count(): int public function getActive(): array { - return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived); + return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived)); } public function activeThreadCount(): int @@ -49,7 +54,7 @@ public function activeThreadCount(): int public function getArchived(): array { - return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived); + return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived)); } public function archivedThreadCount(): int @@ -79,6 +84,14 @@ public function canUpdateAny(): bool return false; } + public function loadVisibleHtml(): void + { + foreach ($this->comments as $comment) { + $comment->setAttribute('html', $comment->safeHtml()); + $comment->makeVisible('html'); + } + } + /** * @param Comment[] $comments * @return CommentTreeNode[] @@ -123,6 +136,9 @@ protected function createTreeNodeForId(int $id, int $depth, array &$byId, array return new CommentTreeNode($byId[$id], $depth, $children); } + /** + * @return Comment[] + */ protected function loadComments(): array { if (!$this->enabled()) { diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 033c19a7a33..197018ccafe 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -2,6 +2,7 @@ namespace BookStack\Entities\Controllers; +use BookStack\Activity\Tools\CommentTree; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\PageRepo; @@ -88,21 +89,32 @@ public function create(Request $request) /** * View the details of a single page. * Pages will always have HTML content. They may have markdown content - * if the markdown editor was used to last update the page. + * if the Markdown editor was used to last update the page. * - * The 'html' property is the fully rendered & escaped HTML content that BookStack + * The 'html' property is the fully rendered and escaped HTML content that BookStack * would show on page view, with page includes handled. * The 'raw_html' property is the direct database stored HTML content, which would be * what BookStack shows on page edit. * * See the "Content Security" section of these docs for security considerations when using * the page content returned from this endpoint. + * + * Comments for the page are provided in a tree-structure representing the hierarchy of top-level + * comments and replies, for both archived and active comments. */ public function read(string $id) { $page = $this->queries->findVisibleByIdOrFail($id); - return response()->json($page->forJsonDisplay()); + $page = $page->forJsonDisplay(); + $commentTree = (new CommentTree($page)); + $commentTree->loadVisibleHtml(); + $page->setAttribute('comments', [ + 'active' => $commentTree->getActive(), + 'archived' => $commentTree->getArchived(), + ]); + + return response()->json($page); } /** diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php index 844bc399381..81022e0d426 100644 --- a/database/factories/Activity/Models/CommentFactory.php +++ b/database/factories/Activity/Models/CommentFactory.php @@ -13,6 +13,11 @@ class CommentFactory extends Factory */ protected $model = \BookStack\Activity\Models\Comment::class; + /** + * A static counter to provide a unique local_id for each comment. + */ + protected static int $nextLocalId = 1000; + /** * Define the model's default state. * @@ -22,11 +27,12 @@ public function definition() { $text = $this->faker->paragraph(1); $html = '

' . $text . '

'; + $nextLocalId = static::$nextLocalId++; return [ 'html' => $html, 'parent_id' => null, - 'local_id' => 1, + 'local_id' => $nextLocalId, 'content_ref' => '', 'archived' => false, ]; diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index 8caf85affbe..d71b6c9881d 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -2,6 +2,7 @@ namespace Tests\Api; +use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use Carbon\Carbon; @@ -199,6 +200,31 @@ public function test_read_endpoint_returns_not_found() $this->assertSame(404, $resp->json('error')['code']); } + public function test_read_endpoint_includes_page_comments_tree_structure() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + $relation = ['commentable_type' => 'page', 'commentable_id' => $page->id]; + $active = Comment::factory()->create([...$relation, 'html' => '

My active comment

']); + Comment::factory()->count(5)->create([...$relation, 'parent_id' => $active->local_id]); + $archived = Comment::factory()->create([...$relation, 'archived' => true]); + Comment::factory()->count(2)->create([...$relation, 'parent_id' => $archived->local_id]); + + $resp = $this->getJson("{$this->baseEndpoint}/{$page->id}"); + $resp->assertOk(); + + $resp->assertJsonCount(1, 'comments.active'); + $resp->assertJsonCount(1, 'comments.archived'); + $resp->assertJsonCount(5, 'comments.active.0.children'); + $resp->assertJsonCount(2, 'comments.archived.0.children'); + + $resp->assertJsonFragment([ + 'id' => $active->id, + 'local_id' => $active->local_id, + 'html' => '

My active comment

', + ]); + } + public function test_update_endpoint() { $this->actingAsApiEditor(); From 9c4a9225af6f7fdcaf8494e8562197d831d25237 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 24 Oct 2025 14:22:53 +0100 Subject: [PATCH 014/398] Comments API: Addressed failing tests and static testing --- app/Activity/CommentRepo.php | 1 + app/Entities/Models/Entity.php | 1 + app/Http/ApiController.php | 2 +- app/Search/SearchRunner.php | 4 ++-- .../Activity/Models/CommentFactory.php | 5 ++++ tests/Activity/WatchTest.php | 4 ++-- tests/Entity/CommentDisplayTest.php | 6 ++--- tests/Entity/CommentStoreTest.php | 23 ++++++++++--------- 8 files changed, 27 insertions(+), 19 deletions(-) diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 20513bcd4f5..13808903d96 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -31,6 +31,7 @@ public function getVisibleById(int $id): Comment /** * Start a query for comments visible to the user. + * @return Builder */ public function getQueryForVisible(): Builder { diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index c6839c15aa0..77393cbbc4e 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -237,6 +237,7 @@ public function tags(): MorphMany /** * Get the comments for an entity. + * @return MorphMany */ public function comments(bool $orderByCreated = true): MorphMany { diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index ac8844b811c..8c0f206d0d5 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -12,7 +12,7 @@ abstract class ApiController extends Controller * The validation rules for this controller. * Can alternative be defined in a rules() method is they need to be dynamic. * - * @var array + * @var array> */ protected array $rules = []; diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index c14d1c642d9..a1ffeee50fd 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -461,9 +461,9 @@ protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bo { $commentsTable = DB::getTablePrefix() . 'comments'; $morphClass = str_replace('\\', '\\\\', $model->getMorphClass()); - $commentQuery = DB::raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments'); + $commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c1.commentable_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments'); - $query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.entity_id')) + $query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.commentable_id')) ->orderBy('last_commented', $negated ? 'asc' : 'desc'); } } diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php index 81022e0d426..2b7fb9ac901 100644 --- a/database/factories/Activity/Models/CommentFactory.php +++ b/database/factories/Activity/Models/CommentFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories\Activity\Models; +use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; class CommentFactory extends Factory @@ -29,12 +30,16 @@ public function definition() $html = '

' . $text . '

'; $nextLocalId = static::$nextLocalId++; + $user = User::query()->first(); + return [ 'html' => $html, 'parent_id' => null, 'local_id' => $nextLocalId, 'content_ref' => '', 'archived' => false, + 'created_by' => $user ?? User::factory(), + 'updated_by' => $user ?? User::factory(), ]; } } diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php index c405b07aed7..f6f3b71c1f2 100644 --- a/tests/Activity/WatchTest.php +++ b/tests/Activity/WatchTest.php @@ -340,8 +340,8 @@ public function test_notifications_sent_in_right_language() ActivityType::PAGE_CREATE => $entities['page'], ActivityType::PAGE_UPDATE => $entities['page'], ActivityType::COMMENT_CREATE => Comment::factory()->make([ - 'entity_id' => $entities['page']->id, - 'entity_type' => $entities['page']->getMorphClass(), + 'commentable_id' => $entities['page']->id, + 'commentable_type' => $entities['page']->getMorphClass(), ]), ]; diff --git a/tests/Entity/CommentDisplayTest.php b/tests/Entity/CommentDisplayTest.php index bffe29fa95b..80664890a7e 100644 --- a/tests/Entity/CommentDisplayTest.php +++ b/tests/Entity/CommentDisplayTest.php @@ -72,8 +72,8 @@ public function test_comment_editor_js_loaded_with_create_or_edit_permissions() Comment::factory()->create([ 'created_by' => $editor->id, - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, ]); $resp = $this->actingAs($editor)->get($page->getUrl()); @@ -84,7 +84,7 @@ public function test_comment_editor_js_loaded_with_create_or_edit_permissions() public function test_comment_displays_relative_times() { $page = $this->entities->page(); - $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]); + $comment = Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => $page->getMorphClass()]); $comment->created_at = now()->subWeek(); $comment->updated_at = now()->subDay(); $comment->save(); diff --git a/tests/Entity/CommentStoreTest.php b/tests/Entity/CommentStoreTest.php index de093a3a6e9..c4c959c2955 100644 --- a/tests/Entity/CommentStoreTest.php +++ b/tests/Entity/CommentStoreTest.php @@ -14,6 +14,7 @@ public function test_add_comment() $this->asAdmin(); $page = $this->entities->page(); + Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => 'page', 'local_id' => 2]); $comment = Comment::factory()->make(['parent_id' => 2]); $resp = $this->postJson("/comment/$page->id", $comment->getAttributes()); @@ -24,9 +25,9 @@ public function test_add_comment() $pageResp->assertSee($comment->html, false); $this->assertDatabaseHas('comments', [ - 'local_id' => 1, - 'entity_id' => $page->id, - 'entity_type' => Page::newModelInstance()->getMorphClass(), + 'local_id' => 3, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', 'parent_id' => 2, ]); @@ -52,9 +53,9 @@ public function test_add_comment_stores_content_reference_only_if_format_valid() ]); if ($valid) { - $this->assertDatabaseHas('comments', ['entity_id' => $page->id, 'content_ref' => $ref]); + $this->assertDatabaseHas('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]); } else { - $this->assertDatabaseMissing('comments', ['entity_id' => $page->id, 'content_ref' => $ref]); + $this->assertDatabaseMissing('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]); } } } @@ -79,7 +80,7 @@ public function test_comment_edit() $this->assertDatabaseHas('comments', [ 'html' => $newHtml, - 'entity_id' => $page->id, + 'commentable_id' => $page->id, ]); $this->assertActivityExists(ActivityType::COMMENT_UPDATE); @@ -218,7 +219,7 @@ public function test_scripts_are_removed_even_if_already_in_db() $page = $this->entities->page(); Comment::factory()->create([ 'html' => '

scriptincommentest

', - 'entity_type' => 'page', 'entity_id' => $page + 'commentable_type' => 'page', 'commentable_id' => $page ]); $resp = $this->asAdmin()->get($page->getUrl()); @@ -236,8 +237,8 @@ public function test_comment_html_is_limited() $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); $resp->assertOk(); $this->assertDatabaseHas('comments', [ - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, 'html' => $expected, ]); @@ -259,8 +260,8 @@ public function test_comment_html_spans_are_cleaned() $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); $resp->assertOk(); $this->assertDatabaseHas('comments', [ - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, 'html' => $expected, ]); From a949900570c80c9c46c140a57c17e2d42738232e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 24 Oct 2025 15:14:25 +0100 Subject: [PATCH 015/398] API: Added examples for comments Tweaked comment repo to avoid returning a lot of extra data on API update responses. --- app/Activity/CommentRepo.php | 1 + dev/api/requests/comments-create.json | 5 ++ dev/api/requests/comments-update.json | 4 ++ dev/api/responses/comments-create.json | 13 +++++ dev/api/responses/comments-list.json | 29 ++++++++++ dev/api/responses/comments-read.json | 38 ++++++++++++++ dev/api/responses/comments-update.json | 13 +++++ dev/api/responses/pages-read.json | 73 ++++++++++++++++++++++++++ 8 files changed, 176 insertions(+) create mode 100644 dev/api/requests/comments-create.json create mode 100644 dev/api/requests/comments-update.json create mode 100644 dev/api/responses/comments-create.json create mode 100644 dev/api/responses/comments-list.json create mode 100644 dev/api/responses/comments-read.json create mode 100644 dev/api/responses/comments-update.json diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 13808903d96..1802e390585 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -74,6 +74,7 @@ public function create(Entity $entity, string $html, ?int $parentId, string $con ActivityService::add(ActivityType::COMMENT_CREATE, $comment); ActivityService::add(ActivityType::COMMENTED_ON, $entity); + $comment->refresh()->unsetRelations(); return $comment; } diff --git a/dev/api/requests/comments-create.json b/dev/api/requests/comments-create.json new file mode 100644 index 00000000000..abc6832d8ec --- /dev/null +++ b/dev/api/requests/comments-create.json @@ -0,0 +1,5 @@ +{ + "page_id": 2646, + "html": "

Can the title be updated?

", + "content_ref": "bkmrk-page-title:7341676876991010:3-14" +} \ No newline at end of file diff --git a/dev/api/requests/comments-update.json b/dev/api/requests/comments-update.json new file mode 100644 index 00000000000..bbcf114f30d --- /dev/null +++ b/dev/api/requests/comments-update.json @@ -0,0 +1,4 @@ +{ + "html": "

Can this comment be updated??????

", + "archived": true +} \ No newline at end of file diff --git a/dev/api/responses/comments-create.json b/dev/api/responses/comments-create.json new file mode 100644 index 00000000000..6fec5c101ab --- /dev/null +++ b/dev/api/responses/comments-create.json @@ -0,0 +1,13 @@ +{ + "id": 167, + "commentable_id": 2646, + "commentable_type": "page", + "parent_id": null, + "local_id": 29, + "created_by": 1, + "updated_by": 1, + "created_at": "2025-10-24T14:05:41.000000Z", + "updated_at": "2025-10-24T14:05:41.000000Z", + "content_ref": "bkmrk-page-title:7341676876991010:3-14", + "archived": false +} \ No newline at end of file diff --git a/dev/api/responses/comments-list.json b/dev/api/responses/comments-list.json new file mode 100644 index 00000000000..a0f638120a2 --- /dev/null +++ b/dev/api/responses/comments-list.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "id": 1, + "commentable_id": 2607, + "commentable_type": "page", + "parent_id": null, + "local_id": 1, + "content_ref": "", + "created_by": 1, + "updated_by": 1, + "created_at": "2022-04-20T08:43:27.000000Z", + "updated_at": "2022-04-20T08:43:27.000000Z" + }, + { + "id": 18, + "commentable_id": 2607, + "commentable_type": "page", + "parent_id": 1, + "local_id": 2, + "content_ref": "", + "created_by": 3, + "updated_by": 3, + "created_at": "2022-11-15T08:12:35.000000Z", + "updated_at": "2022-11-15T08:12:35.000000Z" + } + ], + "total": 88 +} \ No newline at end of file diff --git a/dev/api/responses/comments-read.json b/dev/api/responses/comments-read.json new file mode 100644 index 00000000000..054b8ad1900 --- /dev/null +++ b/dev/api/responses/comments-read.json @@ -0,0 +1,38 @@ +{ + "id": 22, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

This page looks great!<\/p>\n", + "parent_id": null, + "local_id": 2, + "created_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "created_at": "2023-06-07T07:50:56.000000Z", + "updated_at": "2023-06-07T07:50:56.000000Z", + "content_ref": "", + "archived": false, + "replies": [ + { + "id": 34, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

Thanks for the comment!<\/p>\n", + "parent_id": 2, + "local_id": 10, + "created_by": 2, + "updated_by": 2, + "created_at": "2023-06-07T13:46:25.000000Z", + "updated_at": "2023-06-07T13:46:25.000000Z", + "content_ref": "", + "archived": false + } + ] +} \ No newline at end of file diff --git a/dev/api/responses/comments-update.json b/dev/api/responses/comments-update.json new file mode 100644 index 00000000000..ce5ed264441 --- /dev/null +++ b/dev/api/responses/comments-update.json @@ -0,0 +1,13 @@ +{ + "id": 167, + "commentable_id": 2646, + "commentable_type": "page", + "parent_id": null, + "local_id": 29, + "created_by": 1, + "updated_by": 1, + "created_at": "2025-10-24T14:05:41.000000Z", + "updated_at": "2025-10-24T14:09:56.000000Z", + "content_ref": "bkmrk-page-title:7341676876991010:3-14", + "archived": true +} \ No newline at end of file diff --git a/dev/api/responses/pages-read.json b/dev/api/responses/pages-read.json index 22ff2de84af..e38a9cf9242 100644 --- a/dev/api/responses/pages-read.json +++ b/dev/api/responses/pages-read.json @@ -29,6 +29,79 @@ "revision_count": 5, "template": false, "editor": "wysiwyg", + "comments": { + "active": [ + { + "comment": { + "id": 22, + "commentable_id": 306, + "commentable_type": "page", + "html": "

Does this need revising?<\/p>\n", + "parent_id": null, + "local_id": 1, + "created_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_by": 1, + "created_at": "2023-06-07T07:50:56.000000Z", + "updated_at": "2023-06-07T07:50:56.000000Z", + "content_ref": "", + "archived": false + }, + "depth": 0, + "children": [ + { + "comment": { + "id": 34, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

I think it's okay!<\/p>\n", + "parent_id": 1, + "local_id": 2, + "created_by": { + "id": 2, + "name": "Editor", + "slug": "editor" + }, + "updated_by": 1, + "created_at": "2023-06-07T13:46:25.000000Z", + "updated_at": "2023-06-07T13:46:25.000000Z", + "content_ref": "", + "archived": false + }, + "depth": 1, + "children": [] + } + ] + } + ], + "archived": [ + { + "comment": { + "id": 21, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

The title needs to be fixed<\/p>\n", + "parent_id": null, + "local_id": 3, + "created_by": { + "id": 2, + "name": "Editor", + "slug": "editor" + }, + "updated_by": 1, + "created_at": "2023-06-07T07:50:49.000000Z", + "updated_at": "2025-10-24T08:37:22.000000Z", + "content_ref": "", + "archived": true + }, + "depth": 0, + "children": [] + } + ] + }, "tags": [ { "name": "Category", From 0b265733142a21de91ed90d0e197c48b0561bb1e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Oct 2025 17:23:15 +0000 Subject: [PATCH 016/398] Search: Started work to make search result size consistent --- app/Entities/Models/Entity.php | 13 ++ app/Entities/Models/EntityTable.php | 37 +++++ app/Entities/Queries/EntityQueries.php | 26 +++- app/Entities/Tools/EntityHydrator.php | 151 +++++++++++++++++++ app/Entities/Tools/MixedEntityListLoader.php | 2 +- app/Search/SearchRunner.php | 134 +++++++--------- 6 files changed, 284 insertions(+), 79 deletions(-) create mode 100644 app/Entities/Models/EntityTable.php create mode 100644 app/Entities/Tools/EntityHydrator.php diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 77393cbbc4e..b47a029ece8 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -471,4 +471,17 @@ protected function getContentsAttributes(): array return $contentFields; } + + /** + * Create a new instance for the given entity type. + */ + public static function instanceFromType(string $type): self + { + return match ($type) { + 'page' => new Page(), + 'chapter' => new Chapter(), + 'book' => new Book(), + 'bookshelf' => new Bookshelf(), + }; + } } diff --git a/app/Entities/Models/EntityTable.php b/app/Entities/Models/EntityTable.php new file mode 100644 index 00000000000..50112a8e9a5 --- /dev/null +++ b/app/Entities/Models/EntityTable.php @@ -0,0 +1,37 @@ +make(PermissionApplicator::class)->restrictEntityQuery($query); + } + + /** + * Get the entity jointPermissions this is connected to. + */ + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id')->whereColumn('entity_type', '=', 'entities.type'); + } +} diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php index a7a037916d5..c27cc61ccb1 100644 --- a/app/Entities/Queries/EntityQueries.php +++ b/app/Entities/Queries/EntityQueries.php @@ -3,7 +3,10 @@ namespace BookStack\Entities\Queries; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\EntityTable; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\JoinClause; +use Illuminate\Support\Facades\DB; use InvalidArgumentException; class EntityQueries @@ -32,12 +35,31 @@ public function findVisibleByStringIdentifier(string $identifier): ?Entity return $queries->findVisibleById($entityId); } + /** + * Start a query across all entity types. + * Combines the description/text fields into a single 'description' field. + * @return Builder + */ + public function visibleForList(): Builder + { + $rawDescriptionField = DB::raw('COALESCE(description, text) as description'); + return EntityTable::query()->scopes('visible') + ->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', $rawDescriptionField]) + ->leftJoin('entity_container_data', function (JoinClause $join) { + $join->on('entity_container_data.entity_id', '=', 'entities.id') + ->on('entity_container_data.entity_type', '=', 'entities.type'); + })->leftJoin('entity_page_data', function (JoinClause $join) { + $join->on('entity_page_data.page_id', '=', 'entities.id') + ->where('entities.type', '=', 'page'); + }); + } + /** * Start a query of visible entities of the given type, * suitable for listing display. * @return Builder */ - public function visibleForList(string $entityType): Builder + public function visibleForListForType(string $entityType): Builder { $queries = $this->getQueriesForType($entityType); return $queries->visibleForList(); @@ -48,7 +70,7 @@ public function visibleForList(string $entityType): Builder * suitable for using the contents of the items. * @return Builder */ - public function visibleForContent(string $entityType): Builder + public function visibleForContentForType(string $entityType): Builder { $queries = $this->getQueriesForType($entityType); return $queries->visibleForContent(); diff --git a/app/Entities/Tools/EntityHydrator.php b/app/Entities/Tools/EntityHydrator.php new file mode 100644 index 00000000000..fbfc8762e72 --- /dev/null +++ b/app/Entities/Tools/EntityHydrator.php @@ -0,0 +1,151 @@ +entities = $entities; + $this->loadTags = $loadTags; + $this->loadParents = $loadParents; + } + + /** + * Hydrate the entities of this hydrator to return a list of entities represented + * in their original intended models. + * @return Entity[] + */ + public function hydrate(): array + { + $hydrated = []; + + foreach ($this->entities as $entity) { + $data = $entity->toArray(); + $instance = Entity::instanceFromType($entity->type); + + if ($instance instanceof Page) { + $data['text'] = $data['description']; + unset($data['description']); + } + + $instance->forceFill($data); + $hydrated[] = $instance; + } + + if ($this->loadTags) { + $this->loadTagsIntoModels($hydrated); + } + + if ($this->loadParents) { + $this->loadParentsIntoModels($hydrated); + } + + return $hydrated; + } + + /** + * @param Entity[] $entities + */ + protected function loadTagsIntoModels(array $entities): void + { + $idsByType = []; + $entityMap = []; + foreach ($entities as $entity) { + if (!isset($idsByType[$entity->type])) { + $idsByType[$entity->type] = []; + } + $idsByType[$entity->type][] = $entity->id; + $entityMap[$entity->type . ':' . $entity->id] = $entity; + } + + $query = Tag::query(); + foreach ($idsByType as $type => $ids) { + $query->orWhere(function ($query) use ($type, $ids) { + $query->where('entity_type', '=', $type) + ->whereIn('entity_id', $ids); + }); + } + + $tags = empty($idsByType) ? [] : $query->get()->all(); + $tagMap = []; + foreach ($tags as $tag) { + $key = $tag->entity_type . ':' . $tag->entity_id; + if (!isset($tagMap[$key])) { + $tagMap[$key] = []; + } + $tagMap[$key][] = $tag; + } + + foreach ($entityMap as $key => $entity) { + $entityTags = new Collection($tagMap[$key] ?? []); + $entity->setRelation('tags', $entityTags); + } + } + + /** + * @param Entity[] $entities + */ + protected function loadParentsIntoModels(array $entities): void + { + $parentsByType = ['book' => [], 'chapter' => []]; + + foreach ($entities as $entity) { + if ($entity->getAttribute('book_id') !== null) { + $parentsByType['book'][] = $entity->getAttribute('book_id'); + } + if ($entity->getAttribute('chapter_id') !== null) { + $parentsByType['chapter'][] = $entity->getAttribute('chapter_id'); + } + } + + // TODO - Inject in? + $queries = app()->make(EntityQueries::class); + + $parentQuery = $queries->visibleForList(); + $filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0; + $parentQuery = $parentQuery->where(function ($query) use ($parentsByType) { + foreach ($parentsByType as $type => $ids) { + if (count($ids) > 0) { + $query = $query->orWhere(function ($query) use ($type, $ids) { + $query->where('type', '=', $type) + ->whereIn('id', $ids); + }); + } + } + }); + + $parents = $filtered ? (new EntityHydrator($parentQuery->get()->all()))->hydrate() : []; + $parentMap = []; + foreach ($parents as $parent) { + $parentMap[$parent->type . ':' . $parent->id] = $parent; + } + + foreach ($entities as $entity) { + if ($entity instanceof Page || $entity instanceof Chapter) { + $key = 'book:' . $entity->getAttribute('book_id'); + $entity->setRelation('book', $parentMap[$key] ?? null); + } + if ($entity instanceof Page) { + $key = 'chapter:' . $entity->getAttribute('chapter_id'); + $entity->setRelation('chapter', $parentMap[$key] ?? null); + } + } + } +} diff --git a/app/Entities/Tools/MixedEntityListLoader.php b/app/Entities/Tools/MixedEntityListLoader.php index 0a0f224d86c..9987cc061ce 100644 --- a/app/Entities/Tools/MixedEntityListLoader.php +++ b/app/Entities/Tools/MixedEntityListLoader.php @@ -54,7 +54,7 @@ protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, $modelMap = []; foreach ($idsByType as $type => $ids) { - $base = $withContents ? $this->queries->visibleForContent($type) : $this->queries->visibleForList($type); + $base = $withContents ? $this->queries->visibleForContentForType($type) : $this->queries->visibleForListForType($type); $models = $base->whereIn('id', $ids) ->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : []) ->get(); diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index a1ffeee50fd..72ebe96bb4e 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -4,16 +4,16 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; +use BookStack\Entities\Tools\EntityHydrator; use BookStack\Permissions\PermissionApplicator; use BookStack\Search\Options\TagSearchOption; use BookStack\Users\Models\User; use Illuminate\Database\Connection; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Collection as EloquentCollection; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -22,7 +22,7 @@ class SearchRunner { /** - * Retain a cache of score adjusted terms for specific search options. + * Retain a cache of score-adjusted terms for specific search options. */ protected WeakMap $termAdjustmentCache; @@ -38,6 +38,7 @@ public function __construct( * Search all entities in the system. * The provided count is for each entity to search, * Total returned could be larger and not guaranteed. + * // TODO - Update this comment * * @return array{total: int, count: int, has_more: bool, results: Collection} */ @@ -53,26 +54,12 @@ public function searchEntities(SearchOptions $searchOpts, string $entityType = ' $entityTypesToSearch = explode('|', $filterMap['type']); } - $results = collect(); - $total = 0; - $hasMore = false; - - foreach ($entityTypesToSearch as $entityType) { - if (!in_array($entityType, $entityTypes)) { - continue; - } + $searchQuery = $this->buildQuery($searchOpts, $entityTypesToSearch); + $total = $searchQuery->count(); + $results = $this->getPageOfDataFromQuery($searchQuery, $page, $count); - $searchQuery = $this->buildQuery($searchOpts, $entityType); - $entityTotal = $searchQuery->count(); - $searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityType, $page, $count); - - if ($entityTotal > ($page * $count)) { - $hasMore = true; - } - - $total += $entityTotal; - $results = $results->merge($searchResults); - } + // TODO - Pagination? + $hasMore = ($total > ($page * $count)); return [ 'total' => $total, @@ -119,46 +106,41 @@ public function searchChapter(int $chapterId, string $searchString): Collection /** * Get a page of result data from the given query based on the provided page parameters. */ - protected function getPageOfDataFromQuery(EloquentBuilder $query, string $entityType, int $page = 1, int $count = 20): EloquentCollection + protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection { - $relations = ['tags']; - - if ($entityType === 'page' || $entityType === 'chapter') { - $relations['book'] = function (BelongsTo $query) { - $query->scopes('visible'); - }; - } - - if ($entityType === 'page') { - $relations['chapter'] = function (BelongsTo $query) { - $query->scopes('visible'); - }; - } - - return $query->clone() - ->with(array_filter($relations)) + $entities = $query->clone() +// ->with(array_filter($relations)) ->skip(($page - 1) * $count) ->take($count) ->get(); + + $hydrated = (new EntityHydrator($entities->all(), true, true))->hydrate(); + + // TODO - Load in books for pages/chapters efficiently (scoped to visible) + // TODO - Load in chapters for pages efficiently (scoped to visible) + // TODO - Load in tags efficiently + + return collect($hydrated); } /** * Create a search query for an entity. + * @param string[] $entityTypes */ - protected function buildQuery(SearchOptions $searchOpts, string $entityType): EloquentBuilder + protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder { - $entityModelInstance = $this->entityProvider->get($entityType); - $entityQuery = $this->entityQueries->visibleForList($entityType); + $entityQuery = $this->entityQueries->visibleForList() + ->whereIn('type', $entityTypes); // Handle normal search terms - $this->applyTermSearch($entityQuery, $searchOpts, $entityType); + $this->applyTermSearch($entityQuery, $searchOpts, $entityTypes); // Handle exact term matching foreach ($searchOpts->exacts->all() as $exact) { - $filter = function (EloquentBuilder $query) use ($exact, $entityModelInstance) { + $filter = function (EloquentBuilder $query) use ($exact) { $inputTerm = str_replace('\\', '\\\\', $exact->value); $query->where('name', 'like', '%' . $inputTerm . '%') - ->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%'); + ->orWhere('description', 'like', '%' . $inputTerm . '%'); }; $exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter); @@ -173,7 +155,7 @@ protected function buildQuery(SearchOptions $searchOpts, string $entityType): El foreach ($searchOpts->filters->all() as $filterOption) { $functionName = Str::camel('filter_' . $filterOption->getKey()); if (method_exists($this, $functionName)) { - $this->$functionName($entityQuery, $entityModelInstance, $filterOption->value, $filterOption->negated); + $this->$functionName($entityQuery, $filterOption->value, $filterOption->negated); } } @@ -183,7 +165,7 @@ protected function buildQuery(SearchOptions $searchOpts, string $entityType): El /** * For the given search query, apply the queries for handling the regular search terms. */ - protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, string $entityType): void + protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, array $entityTypes): void { $terms = $options->searches->toValueArray(); if (count($terms) === 0) { @@ -200,8 +182,6 @@ protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $ ]); $subQuery->addBinding($scoreSelect['bindings'], 'select'); - - $subQuery->where('entity_type', '=', $entityType); $subQuery->where(function (Builder $query) use ($terms) { foreach ($terms as $inputTerm) { $escapedTerm = str_replace('\\', '\\\\', $inputTerm); @@ -210,7 +190,10 @@ protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $ }); $subQuery->groupBy('entity_type', 'entity_id'); - $entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id'); + $entityQuery->joinSub($subQuery, 's', function (JoinClause $join) { + $join->on('s.entity_id', '=', 'entities.id') + ->on('s.entity_type', '=', 'entities.type'); + }); $entityQuery->addSelect('s.score'); $entityQuery->orderBy('score', 'desc'); } @@ -350,31 +333,31 @@ protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, st /** * Custom entity search filters. */ - protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void + protected function filterUpdatedAfter(EloquentBuilder $query, string $input, bool $negated): void { $date = date_create($input); $this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date); } - protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated): void + protected function filterUpdatedBefore(EloquentBuilder $query, string $input, bool $negated): void { $date = date_create($input); $this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date); } - protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void + protected function filterCreatedAfter(EloquentBuilder $query, string $input, bool $negated): void { $date = date_create($input); $this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date); } - protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterCreatedBefore(EloquentBuilder $query, string $input, bool $negated) { $date = date_create($input); $this->applyNegatableWhere($query, $negated, 'created_at', '<', $date); } - protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterCreatedBy(EloquentBuilder $query, string $input, bool $negated) { $userSlug = $input === 'me' ? user()->slug : trim($input); $user = User::query()->where('slug', '=', $userSlug)->first(['id']); @@ -383,7 +366,7 @@ protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string } } - protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterUpdatedBy(EloquentBuilder $query, string $input, bool $negated) { $userSlug = $input === 'me' ? user()->slug : trim($input); $user = User::query()->where('slug', '=', $userSlug)->first(['id']); @@ -392,7 +375,7 @@ protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string } } - protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterOwnedBy(EloquentBuilder $query, string $input, bool $negated) { $userSlug = $input === 'me' ? user()->slug : trim($input); $user = User::query()->where('slug', '=', $userSlug)->first(['id']); @@ -401,27 +384,27 @@ protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $ } } - protected function filterInName(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterInName(EloquentBuilder $query, string $input, bool $negated) { $this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%'); } - protected function filterInTitle(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterInTitle(EloquentBuilder $query, string $input, bool $negated) { - $this->filterInName($query, $model, $input, $negated); + $this->filterInName($query, $input, $negated); } - protected function filterInBody(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterInBody(EloquentBuilder $query, string $input, bool $negated) { - $this->applyNegatableWhere($query, $negated, $model->textField, 'like', '%' . $input . '%'); + $this->applyNegatableWhere($query, $negated, 'description', 'like', '%' . $input . '%'); } - protected function filterIsRestricted(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterIsRestricted(EloquentBuilder $query, string $input, bool $negated) { $negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions'); } - protected function filterViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterViewedByMe(EloquentBuilder $query, string $input, bool $negated) { $filter = function ($query) { $query->where('user_id', '=', user()->id); @@ -430,7 +413,7 @@ protected function filterViewedByMe(EloquentBuilder $query, Entity $model, strin $negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter); } - protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterNotViewedByMe(EloquentBuilder $query, string $input, bool $negated) { $filter = function ($query) { $query->where('user_id', '=', user()->id); @@ -439,31 +422,30 @@ protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, st $negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter); } - protected function filterIsTemplate(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterIsTemplate(EloquentBuilder $query, string $input, bool $negated) { - if ($model instanceof Page) { - $this->applyNegatableWhere($query, $negated, 'template', '=', true); - } + $this->applyNegatableWhere($query, $negated, 'template', '=', true); } - protected function filterSortBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterSortBy(EloquentBuilder $query, string $input, bool $negated) { $functionName = Str::camel('sort_by_' . $input); if (method_exists($this, $functionName)) { - $this->$functionName($query, $model, $negated); + $this->$functionName($query, $negated); } } /** * Sorting filter options. */ - protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bool $negated) + protected function sortByLastCommented(EloquentBuilder $query, bool $negated) { $commentsTable = DB::getTablePrefix() . 'comments'; - $morphClass = str_replace('\\', '\\\\', $model->getMorphClass()); - $commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c1.commentable_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments'); + $commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c2.created_at IS NULL) as comments'); - $query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.commentable_id')) - ->orderBy('last_commented', $negated ? 'asc' : 'desc'); + $query->join($commentQuery, function (JoinClause $join) { + $join->on('entities.id', '=', 'comments.commentable_id') + ->on('entities.type', '=', 'comments.commentable_type'); + })->orderBy('last_commented', $negated ? 'asc' : 'desc'); } } From f0303de2e5aa5a2e7b2bc805653c5cf3039ea290 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Oct 2025 18:02:54 +0000 Subject: [PATCH 017/398] Search: Improved result hydration performance --- app/Entities/Tools/EntityHydrator.php | 15 ++++++++------- app/Search/SearchRunner.php | 5 ----- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/Entities/Tools/EntityHydrator.php b/app/Entities/Tools/EntityHydrator.php index fbfc8762e72..cb62674237c 100644 --- a/app/Entities/Tools/EntityHydrator.php +++ b/app/Entities/Tools/EntityHydrator.php @@ -37,7 +37,7 @@ public function hydrate(): array $hydrated = []; foreach ($this->entities as $entity) { - $data = $entity->toArray(); + $data = $entity->getRawOriginal(); $instance = Entity::instanceFromType($entity->type); if ($instance instanceof Page) { @@ -45,7 +45,7 @@ public function hydrate(): array unset($data['description']); } - $instance->forceFill($data); + $instance = $instance->setRawAttributes($data, true); $hydrated[] = $instance; } @@ -131,7 +131,8 @@ protected function loadParentsIntoModels(array $entities): void } }); - $parents = $filtered ? (new EntityHydrator($parentQuery->get()->all()))->hydrate() : []; + $parentModels = $filtered ? $parentQuery->get()->all() : []; + $parents = (new EntityHydrator($parentModels))->hydrate(); $parentMap = []; foreach ($parents as $parent) { $parentMap[$parent->type . ':' . $parent->id] = $parent; @@ -139,12 +140,12 @@ protected function loadParentsIntoModels(array $entities): void foreach ($entities as $entity) { if ($entity instanceof Page || $entity instanceof Chapter) { - $key = 'book:' . $entity->getAttribute('book_id'); - $entity->setRelation('book', $parentMap[$key] ?? null); + $key = 'book:' . $entity->getRawAttribute('book_id'); + $entity->setAttribute('book', $parentMap[$key] ?? null); } if ($entity instanceof Page) { - $key = 'chapter:' . $entity->getAttribute('chapter_id'); - $entity->setRelation('chapter', $parentMap[$key] ?? null); + $key = 'chapter:' . $entity->getRawAttribute('chapter_id'); + $entity->setAttribute('chapter', $parentMap[$key] ?? null); } } } diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 72ebe96bb4e..4b19aceac7a 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -109,17 +109,12 @@ public function searchChapter(int $chapterId, string $searchString): Collection protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection { $entities = $query->clone() -// ->with(array_filter($relations)) ->skip(($page - 1) * $count) ->take($count) ->get(); $hydrated = (new EntityHydrator($entities->all(), true, true))->hydrate(); - // TODO - Load in books for pages/chapters efficiently (scoped to visible) - // TODO - Load in chapters for pages efficiently (scoped to visible) - // TODO - Load in tags efficiently - return collect($hydrated); } From 3fd25bd03e15a965213d61b5887b15ba3d172eef Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 28 Oct 2025 20:37:41 +0000 Subject: [PATCH 018/398] Search: Added pagination, updated other search uses Also updated hydrator to be created via injection. --- app/Entities/Tools/EntityHydrator.php | 32 +++++++++------------------ app/Search/SearchApiController.php | 9 ++++---- app/Search/SearchController.php | 14 +++++++----- app/Search/SearchRunner.php | 29 ++++++------------------ resources/views/search/all.blade.php | 6 +---- 5 files changed, 31 insertions(+), 59 deletions(-) diff --git a/app/Entities/Tools/EntityHydrator.php b/app/Entities/Tools/EntityHydrator.php index cb62674237c..7cabc7ecbec 100644 --- a/app/Entities/Tools/EntityHydrator.php +++ b/app/Entities/Tools/EntityHydrator.php @@ -12,31 +12,22 @@ class EntityHydrator { - /** - * @var EntityTable[] $entities - */ - protected array $entities; - - protected bool $loadTags = false; - protected bool $loadParents = false; - - public function __construct(array $entities, bool $loadTags = false, bool $loadParents = false) - { - $this->entities = $entities; - $this->loadTags = $loadTags; - $this->loadParents = $loadParents; + public function __construct( + protected EntityQueries $entityQueries, + ) { } /** * Hydrate the entities of this hydrator to return a list of entities represented * in their original intended models. + * @param EntityTable[] $entities * @return Entity[] */ - public function hydrate(): array + public function hydrate(array $entities, bool $loadTags = false, bool $loadParents = false): array { $hydrated = []; - foreach ($this->entities as $entity) { + foreach ($entities as $entity) { $data = $entity->getRawOriginal(); $instance = Entity::instanceFromType($entity->type); @@ -49,11 +40,11 @@ public function hydrate(): array $hydrated[] = $instance; } - if ($this->loadTags) { + if ($loadTags) { $this->loadTagsIntoModels($hydrated); } - if ($this->loadParents) { + if ($loadParents) { $this->loadParentsIntoModels($hydrated); } @@ -115,10 +106,7 @@ protected function loadParentsIntoModels(array $entities): void } } - // TODO - Inject in? - $queries = app()->make(EntityQueries::class); - - $parentQuery = $queries->visibleForList(); + $parentQuery = $this->entityQueries->visibleForList(); $filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0; $parentQuery = $parentQuery->where(function ($query) use ($parentsByType) { foreach ($parentsByType as $type => $ids) { @@ -132,7 +120,7 @@ protected function loadParentsIntoModels(array $entities): void }); $parentModels = $filtered ? $parentQuery->get()->all() : []; - $parents = (new EntityHydrator($parentModels))->hydrate(); + $parents = $this->hydrate($parentModels); $parentMap = []; foreach ($parents as $parent) { $parentMap[$parent->type . ':' . $parent->id] = $parent; diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index cd4a14a3931..5de7a511036 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -1,10 +1,13 @@ validate($request, $this->rules['all']); diff --git a/app/Search/SearchController.php b/app/Search/SearchController.php index 2fce6a3d53f..9586beffba2 100644 --- a/app/Search/SearchController.php +++ b/app/Search/SearchController.php @@ -7,6 +7,7 @@ use BookStack\Entities\Tools\SiblingFetcher; use BookStack\Http\Controller; use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; class SearchController extends Controller { @@ -23,20 +24,21 @@ public function search(Request $request, SearchResultsFormatter $formatter) { $searchOpts = SearchOptions::fromRequest($request); $fullSearchString = $searchOpts->toString(); - $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString])); - $page = intval($request->get('page', '0')) ?: 1; - $nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1)); $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20); $formatter->format($results['results']->all(), $searchOpts); + $paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page); + $paginator->setPath('/search'); + $paginator->appends($request->except('page')); + + $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString])); return view('search.all', [ 'entities' => $results['results'], 'totalResults' => $results['total'], + 'paginator' => $paginator, 'searchTerm' => $fullSearchString, - 'hasNextPage' => $results['has_more'], - 'nextPageLink' => $nextPageLink, 'options' => $searchOpts, ]); } @@ -128,7 +130,7 @@ public function searchSuggestions(Request $request) } /** - * Search siblings items in the system. + * Search sibling items in the system. */ public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher) { diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 4b19aceac7a..0128e38040d 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -11,7 +11,6 @@ use BookStack\Users\Models\User; use Illuminate\Database\Connection; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Collection; @@ -30,17 +29,15 @@ public function __construct( protected EntityProvider $entityProvider, protected PermissionApplicator $permissions, protected EntityQueries $entityQueries, + protected EntityHydrator $entityHydrator, ) { $this->termAdjustmentCache = new WeakMap(); } /** * Search all entities in the system. - * The provided count is for each entity to search, - * Total returned could be larger and not guaranteed. - * // TODO - Update this comment * - * @return array{total: int, count: int, has_more: bool, results: Collection} + * @return array{total: int, results: Collection} */ public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array { @@ -58,14 +55,9 @@ public function searchEntities(SearchOptions $searchOpts, string $entityType = ' $total = $searchQuery->count(); $results = $this->getPageOfDataFromQuery($searchQuery, $page, $count); - // TODO - Pagination? - $hasMore = ($total > ($page * $count)); - return [ 'total' => $total, - 'count' => count($results), - 'has_more' => $hasMore, - 'results' => $results->sortByDesc('score')->values(), + 'results' => $results->values(), ]; } @@ -79,15 +71,8 @@ public function searchBook(int $bookId, string $searchString): Collection $filterMap = $opts->filters->toValueMap(); $entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes; - $results = collect(); - foreach ($entityTypesToSearch as $entityType) { - if (!in_array($entityType, $entityTypes)) { - continue; - } - - $search = $this->buildQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get(); - $results = $results->merge($search); - } + $filteredTypes = array_intersect($entityTypesToSearch, $entityTypes); + $results = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId)->take(20)->get(); return $results->sortByDesc('score')->take(20); } @@ -98,7 +83,7 @@ public function searchBook(int $bookId, string $searchString): Collection public function searchChapter(int $chapterId, string $searchString): Collection { $opts = SearchOptions::fromString($searchString); - $pages = $this->buildQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get(); + $pages = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId)->take(20)->get(); return $pages->sortByDesc('score'); } @@ -113,7 +98,7 @@ protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int ->take($count) ->get(); - $hydrated = (new EntityHydrator($entities->all(), true, true))->hydrate(); + $hydrated = $this->entityHydrator->hydrate($entities->all(), true, true); return collect($hydrated); } diff --git a/resources/views/search/all.blade.php b/resources/views/search/all.blade.php index ad437604b1c..48250816fef 100644 --- a/resources/views/search/all.blade.php +++ b/resources/views/search/all.blade.php @@ -87,11 +87,7 @@ @include('entities.list', ['entities' => $entities, 'showPath' => true, 'showTags' => true])

- @if($hasNextPage) - - @endif + {{ $paginator->render() }}
From 751934c84a3e4c0482ca8086997c485ee6d6f077 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Oct 2025 12:59:34 +0000 Subject: [PATCH 019/398] Search: Tested changes to single-table search Updated filters to use single table where needed. --- app/Entities/Models/EntityTable.php | 34 +++++++++++++++++++++++++- app/Entities/Queries/EntityQueries.php | 9 ++++++- app/Entities/Tools/EntityHydrator.php | 4 +-- app/Search/SearchRunner.php | 18 ++++++++------ tests/Api/SearchApiTest.php | 1 + tests/Search/EntitySearchTest.php | 6 +++++ 6 files changed, 61 insertions(+), 11 deletions(-) diff --git a/app/Entities/Models/EntityTable.php b/app/Entities/Models/EntityTable.php index 50112a8e9a5..5780162d1d2 100644 --- a/app/Entities/Models/EntityTable.php +++ b/app/Entities/Models/EntityTable.php @@ -2,11 +2,15 @@ namespace BookStack\Entities\Models; +use BookStack\Activity\Models\Tag; +use BookStack\Activity\Models\View; use BookStack\App\Model; +use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\PermissionApplicator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -32,6 +36,34 @@ public function scopeVisible(Builder $query): Builder */ public function jointPermissions(): HasMany { - return $this->hasMany(JointPermission::class, 'entity_id')->whereColumn('entity_type', '=', 'entities.type'); + return $this->hasMany(JointPermission::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get the Tags that have been assigned to entities. + */ + public function tags(): HasMany + { + return $this->hasMany(Tag::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get the assigned permissions. + */ + public function permissions(): HasMany + { + return $this->hasMany(EntityPermission::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get View objects for this entity. + */ + public function views(): HasMany + { + return $this->hasMany(View::class, 'viewable_id') + ->whereColumn('viewable_type', '=', 'entities.type'); } } diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php index c27cc61ccb1..91c6a43633d 100644 --- a/app/Entities/Queries/EntityQueries.php +++ b/app/Entities/Queries/EntityQueries.php @@ -5,6 +5,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\EntityTable; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Facades\DB; use InvalidArgumentException; @@ -43,8 +44,14 @@ public function findVisibleByStringIdentifier(string $identifier): ?Entity public function visibleForList(): Builder { $rawDescriptionField = DB::raw('COALESCE(description, text) as description'); + $bookSlugSelect = function (QueryBuilder $query) { + return $query->select('slug')->from('entities as books') + ->whereColumn('books.id', '=', 'entities.book_id') + ->where('type', '=', 'book'); + }; + return EntityTable::query()->scopes('visible') - ->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', $rawDescriptionField]) + ->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField]) ->leftJoin('entity_container_data', function (JoinClause $join) { $join->on('entity_container_data.entity_id', '=', 'entities.id') ->on('entity_container_data.entity_type', '=', 'entities.type'); diff --git a/app/Entities/Tools/EntityHydrator.php b/app/Entities/Tools/EntityHydrator.php index 7cabc7ecbec..87e39d222ec 100644 --- a/app/Entities/Tools/EntityHydrator.php +++ b/app/Entities/Tools/EntityHydrator.php @@ -129,11 +129,11 @@ protected function loadParentsIntoModels(array $entities): void foreach ($entities as $entity) { if ($entity instanceof Page || $entity instanceof Chapter) { $key = 'book:' . $entity->getRawAttribute('book_id'); - $entity->setAttribute('book', $parentMap[$key] ?? null); + $entity->setRelation('book', $parentMap[$key] ?? null); } if ($entity instanceof Page) { $key = 'chapter:' . $entity->getRawAttribute('chapter_id'); - $entity->setAttribute('chapter', $parentMap[$key] ?? null); + $entity->setRelation('chapter', $parentMap[$key] ?? null); } } } diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 0128e38040d..bfb65cf0f40 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -72,9 +72,9 @@ public function searchBook(int $bookId, string $searchString): Collection $entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes; $filteredTypes = array_intersect($entityTypesToSearch, $entityTypes); - $results = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId)->take(20)->get(); + $query = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId); - return $results->sortByDesc('score')->take(20); + return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score'); } /** @@ -83,9 +83,9 @@ public function searchBook(int $bookId, string $searchString): Collection public function searchChapter(int $chapterId, string $searchString): Collection { $opts = SearchOptions::fromString($searchString); - $pages = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId)->take(20)->get(); + $query = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId); - return $pages->sortByDesc('score'); + return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score'); } /** @@ -120,7 +120,8 @@ protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): El $filter = function (EloquentBuilder $query) use ($exact) { $inputTerm = str_replace('\\', '\\\\', $exact->value); $query->where('name', 'like', '%' . $inputTerm . '%') - ->orWhere('description', 'like', '%' . $inputTerm . '%'); + ->orWhere('description', 'like', '%' . $inputTerm . '%') + ->orWhere('text', 'like', '%' . $inputTerm . '%'); }; $exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter); @@ -301,7 +302,7 @@ protected function applyTagSearch(EloquentBuilder $query, TagSearchOption $optio $option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter); } - protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void + protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string|callable $column, string|null $operator, mixed $value): void { if ($negated) { $query->whereNot($column, $operator, $value); @@ -376,7 +377,10 @@ protected function filterInTitle(EloquentBuilder $query, string $input, bool $ne protected function filterInBody(EloquentBuilder $query, string $input, bool $negated) { - $this->applyNegatableWhere($query, $negated, 'description', 'like', '%' . $input . '%'); + $this->applyNegatableWhere($query, $negated, function (EloquentBuilder $query) use ($input) { + $query->where('description', 'like', '%' . $input . '%') + ->orWhere('text', 'like', '%' . $input . '%'); + }, null, null); } protected function filterIsRestricted(EloquentBuilder $query, string $input, bool $negated) diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 9da7900ca9a..517c5d8e4ef 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -113,6 +113,7 @@ public function test_all_endpoint_includes_parent_details_where_visible() $this->permissions->disableEntityInheritedPermissions($book); $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertOk(); $resp->assertJsonPath('data.0.id', $page->id); $resp->assertJsonMissingPath('data.0.book.name'); } diff --git a/tests/Search/EntitySearchTest.php b/tests/Search/EntitySearchTest.php index d3d85998615..ca900c2edc4 100644 --- a/tests/Search/EntitySearchTest.php +++ b/tests/Search/EntitySearchTest.php @@ -27,6 +27,12 @@ public function test_bookshelf_search() $search->assertSeeText($shelf->name, true); } + public function test_search_shows_pagination() + { + $search = $this->asEditor()->get('/search?term=a'); + $this->withHtml($search)->assertLinkExists('/search?term=a&page=2', '2'); + } + public function test_invalid_page_search() { $resp = $this->asEditor()->get('/search?term=' . urlencode('

test

')); From 02d024aa322e4cd7ef127e1c45940ba122e34eea Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Oct 2025 18:17:51 +0000 Subject: [PATCH 020/398] API: Added endpoints for reading image data --- app/Config/filesystems.php | 2 +- .../Controllers/ImageGalleryApiController.php | 40 +++++++++++- app/Uploads/Image.php | 4 +- app/Uploads/ImageService.php | 17 +++++ .../image-gallery-readDataForUrl.http | 1 + routes/api.php | 2 + tests/Api/ImageGalleryApiTest.php | 63 +++++++++++++++++++ 7 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 dev/api/requests/image-gallery-readDataForUrl.http diff --git a/app/Config/filesystems.php b/app/Config/filesystems.php index ab73fec2968..facf5f2df2f 100644 --- a/app/Config/filesystems.php +++ b/app/Config/filesystems.php @@ -11,7 +11,7 @@ return [ // Default Filesystem Disk - // Options: local, local_secure, s3 + // Options: local, local_secure, local_secure_restricted, s3 'default' => env('STORAGE_TYPE', 'local'), // Filesystem to use specifically for image uploads. diff --git a/app/Uploads/Controllers/ImageGalleryApiController.php b/app/Uploads/Controllers/ImageGalleryApiController.php index 59696dc9ae4..c4168a77e94 100644 --- a/app/Uploads/Controllers/ImageGalleryApiController.php +++ b/app/Uploads/Controllers/ImageGalleryApiController.php @@ -3,11 +3,13 @@ namespace BookStack\Uploads\Controllers; use BookStack\Entities\Queries\PageQueries; +use BookStack\Exceptions\NotFoundException; use BookStack\Http\ApiController; use BookStack\Permissions\Permission; use BookStack\Uploads\Image; use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageResizer; +use BookStack\Uploads\ImageService; use Illuminate\Http\Request; class ImageGalleryApiController extends ApiController @@ -20,6 +22,7 @@ public function __construct( protected ImageRepo $imageRepo, protected ImageResizer $imageResizer, protected PageQueries $pageQueries, + protected ImageService $imageService, ) { } @@ -32,6 +35,9 @@ protected function rules(): array 'image' => ['required', 'file', ...$this->getImageValidationRules()], 'name' => ['string', 'max:180'], ], + 'readDataForUrl' => [ + 'url' => ['required', 'string', 'url'], + ], 'update' => [ 'name' => ['string', 'max:180'], 'image' => ['file', ...$this->getImageValidationRules()], @@ -85,7 +91,8 @@ public function create(Request $request) * The "thumbs" response property contains links to scaled variants that BookStack may use in its UI. * The "content" response property provides HTML and Markdown content, in the format that BookStack * would typically use by default to add the image in page content, as a convenience. - * Actual image file data is not provided but can be fetched via the "url" response property. + * Actual image file data is not provided but can be fetched via the "url" response property or by + * using the "read-data" endpoint. */ public function read(string $id) { @@ -94,6 +101,37 @@ public function read(string $id) return response()->json($this->formatForSingleResponse($image)); } + /** + * Read the image file data for a single image in the system. + * The returned response will be a stream of image data instead of a JSON response. + */ + public function readData(string $id) + { + $image = Image::query()->scopes(['visible'])->findOrFail($id); + + return $this->imageService->streamImageFromStorageResponse('gallery', $image->path); + } + + /** + * Read the image file data for a single image in the system, using the provided URL + * to identify the image instead of its ID, which is provided as a "URL" query parameter. + * The returned response will be a stream of image data instead of a JSON response. + */ + public function readDataForUrl(Request $request) + { + $data = $this->validate($request, $this->rules()['readDataForUrl']); + $basePath = url('/uploads/images/'); + $imagePath = str_replace($basePath, '', $data['url']); + + if (!$this->imageService->pathAccessible($imagePath)) { + throw (new NotFoundException(trans('errors.image_not_found'))) + ->setSubtitle(trans('errors.image_not_found_subtitle')) + ->setDetails(trans('errors.image_not_found_details')); + } + + return $this->imageService->streamImageFromStorageResponse('gallery', $imagePath); + } + /** * Update the details of an existing image in the system. * Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index ec8f8be0fb1..20def9de655 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -42,7 +42,9 @@ public function jointPermissions(): HasMany */ public function scopeVisible(Builder $query): Builder { - return app()->make(PermissionApplicator::class)->restrictPageRelationQuery($query, 'images', 'uploaded_to'); + return app()->make(PermissionApplicator::class) + ->restrictPageRelationQuery($query, 'images', 'uploaded_to') + ->whereIn('type', ['gallery', 'drawio']); } /** diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 402456e9791..fadafc8e527 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -264,6 +264,23 @@ public function pathAccessibleInLocalSecure(string $imagePath): bool && str_starts_with($disk->mimeType($imagePath), 'image/'); } + /** + * Check if the given path exists and is accessible depending on the current settings. + */ + public function pathAccessible(string $imagePath): bool + { + $disk = $this->storage->getDisk('gallery'); + + if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) { + return false; + } + + // Check local_secure is active + return $disk->exists($imagePath) + // Check the file is likely an image file + && str_starts_with($disk->mimeType($imagePath), 'image/'); + } + /** * Check that the current user has access to the relation * of the image at the given path. diff --git a/dev/api/requests/image-gallery-readDataForUrl.http b/dev/api/requests/image-gallery-readDataForUrl.http new file mode 100644 index 00000000000..1892600f437 --- /dev/null +++ b/dev/api/requests/image-gallery-readDataForUrl.http @@ -0,0 +1 @@ +GET /api/image-gallery/url/data?url=https%3A%2F%2Fbookstack.example.com%2Fuploads%2Fimages%2Fgallery%2F2025-10%2Fmy-image.png diff --git a/routes/api.php b/routes/api.php index 4b661da5d23..1466e638cb5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -64,7 +64,9 @@ Route::get('image-gallery', [ImageGalleryApiController::class, 'list']); Route::post('image-gallery', [ImageGalleryApiController::class, 'create']); +Route::get('image-gallery/url/data', [ImageGalleryApiController::class, 'readDataForUrl']); Route::get('image-gallery/{id}', [ImageGalleryApiController::class, 'read']); +Route::get('image-gallery/{id}/data', [ImageGalleryApiController::class, 'readData']); Route::put('image-gallery/{id}', [ImageGalleryApiController::class, 'update']); Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']); diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php index 6670931074e..07c20c83416 100644 --- a/tests/Api/ImageGalleryApiTest.php +++ b/tests/Api/ImageGalleryApiTest.php @@ -275,6 +275,69 @@ public function test_read_endpoint_does_not_show_if_no_permissions_for_related_p $resp->assertStatus(404); } + public function test_read_data_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'image/png'); + + $respData = $resp->streamedContent(); + $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData); + } + + public function test_read_data_endpoint_permission_controlled() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + $image = Image::findOrFail($data['response']->id); + + $this->get("{$this->baseEndpoint}/{$image->id}/data")->assertOk(); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data"); + $resp->assertStatus(404); + } + + public function test_read_url_data_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + + $url = url($data['response']->path); + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'image/png'); + + $respData = $resp->streamedContent(); + $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData); + } + + public function test_read_url_data_endpoint_permission_controlled_when_local_secure_restricted_storage_is_used() + { + config()->set('filesystems.images', 'local_secure_restricted'); + + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + + $url = url($data['response']->path); + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(200); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(404); + } + public function test_update_endpoint() { $this->actingAsApiAdmin(); From 570ded10fa7cc17762363dcf622e72664a6fee15 Mon Sep 17 00:00:00 2001 From: lublak <44057030+lublak@users.noreply.github.com> Date: Fri, 31 Oct 2025 12:51:04 +0100 Subject: [PATCH 021/398] Set cursor to pointer for drawio diagrams Add cursor style for drawio diagrams in TinyMCE. --- resources/sass/_tinymce.scss | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index 8cc96df4193..561bb23cae0 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -202,4 +202,12 @@ body.page-content.mce-content-body { background-image: url('data:image/svg+xml;utf8,'); background-position: 50% 50%; background-size: 100% 100%; -} \ No newline at end of file +} + +/** + * Set correct cursor for drawio + */ + +.page-content.mce-content-body [drawio-diagram] { + cursor: pointer; +} From 1df850ea3ec8786444e23340566334517fa56f7a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 31 Oct 2025 15:55:45 +0000 Subject: [PATCH 022/398] Search: Fixed formatting timeout with many term occurrences For #5863 --- app/Search/SearchResultsFormatter.php | 2 +- tests/Search/EntitySearchTest.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/Search/SearchResultsFormatter.php b/app/Search/SearchResultsFormatter.php index b06f81e0e05..7bb5e03cc65 100644 --- a/app/Search/SearchResultsFormatter.php +++ b/app/Search/SearchResultsFormatter.php @@ -91,7 +91,7 @@ protected function getMatchPositions(string $text, array $terms): array $offset = 0; $term = mb_strtolower($term); $pos = mb_strpos($text, $term, $offset); - while ($pos !== false) { + while ($pos !== false && count($matchRefs) < 25) { $end = $pos + mb_strlen($term); $matchRefs[$pos] = $end; $offset = $end; diff --git a/tests/Search/EntitySearchTest.php b/tests/Search/EntitySearchTest.php index ca900c2edc4..8501b65c43d 100644 --- a/tests/Search/EntitySearchTest.php +++ b/tests/Search/EntitySearchTest.php @@ -378,6 +378,21 @@ public function test_match_highlighting_works_with_multibyte_content() $search->assertSee('На мен ми трябва нещо добро test', false); } + public function test_match_highlighting_is_efficient_with_large_frequency_in_content() + { + $content = str_repeat('superbeans ', 10000); + $this->entities->newPage([ + 'name' => 'Test Page', + 'html' => "

{$content}

", + ]); + + $time = microtime(true); + $resp = $this->asEditor()->get('/search?term=' . urlencode('superbeans')); + $this->assertLessThan(0.5, microtime(true) - $time); + + $resp->assertSee('superbeans', false); + } + public function test_html_entities_in_item_details_remains_escaped_in_search_results() { $this->entities->newPage(['name' => 'My TestPageContent', 'html' => '

My supercool <great> TestPageContent page

']); From 4a57933cd1cb3616fbc7193846cfbe9d199b472b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 2 Nov 2025 13:11:38 +0000 Subject: [PATCH 023/398] Deps: Updated PHP composer packages --- composer.lock | 251 +++++++++++++++++++------------------------------- 1 file changed, 97 insertions(+), 154 deletions(-) diff --git a/composer.lock b/composer.lock index 2fbad908a1d..141fe47777b 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.356.42", + "version": "3.359.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "c886d60206d2f100c2d132052875f53ce3f999f7" + "reference": "a32e4c9522f0b61c947fafa1713d3a24b397a757" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c886d60206d2f100c2d132052875f53ce3f999f7", - "reference": "c886d60206d2f100c2d132052875f53ce3f999f7", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a32e4c9522f0b61c947fafa1713d3a24b397a757", + "reference": "a32e4c9522f0b61c947fafa1713d3a24b397a757", "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.356.42" + "source": "https://github.com/aws/aws-sdk-php/tree/3.359.3" }, - "time": "2025-10-17T18:17:41+00:00" + "time": "2025-10-31T18:15:22+00:00" }, { "name": "bacon/bacon-qr-code", @@ -634,16 +634,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.1.3", + "version": "v3.1.4", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "baed300e4fb8226359c04395518059a136e2a2e2" + "reference": "db712c90c5b9868df3600e64e68da62e78a34623" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/baed300e4fb8226359c04395518059a136e2a2e2", - "reference": "baed300e4fb8226359c04395518059a136e2a2e2", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623", "shasum": "" }, "require": { @@ -692,9 +692,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.3" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" }, - "time": "2025-10-14T13:10:17+00:00" + "time": "2025-10-29T12:43:30+00:00" }, { "name": "dompdf/php-font-lib", @@ -789,29 +789,28 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.4.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "8c784d071debd117328803d86b2097615b457500" + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", - "reference": "8c784d071debd117328803d86b2097615b457500", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "shasum": "" }, "require": { - "php": "^7.2|^8.0", - "webmozart/assert": "^1.0" + "php": "^8.2|^8.3|^8.4|^8.5" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" }, "type": "library", "extra": { @@ -842,7 +841,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" }, "funding": [ { @@ -850,7 +849,7 @@ "type": "github" } ], - "time": "2024-10-09T13:47:03+00:00" + "time": "2025-10-31T18:51:33+00:00" }, { "name": "egulias/email-validator", @@ -1739,16 +1738,16 @@ }, { "name": "laravel/framework", - "version": "v12.34.0", + "version": "v12.36.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f9ec5a5d88bc8c468f17b59f88e05c8ac3c8d687" + "reference": "cad110d7685fbab990a6bb8184d0cfd847d7c4d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f9ec5a5d88bc8c468f17b59f88e05c8ac3c8d687", - "reference": "f9ec5a5d88bc8c468f17b59f88e05c8ac3c8d687", + "url": "https://api.github.com/repos/laravel/framework/zipball/cad110d7685fbab990a6bb8184d0cfd847d7c4d8", + "reference": "cad110d7685fbab990a6bb8184d0cfd847d7c4d8", "shasum": "" }, "require": { @@ -1954,7 +1953,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-10-14T13:58:31+00:00" + "time": "2025-10-29T14:20:57+00:00" }, { "name": "laravel/prompts", @@ -2078,16 +2077,16 @@ }, { "name": "laravel/socialite", - "version": "v5.23.0", + "version": "v5.23.1", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5" + "reference": "83d7523c97c1101d288126948947891319eef800" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", - "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "url": "https://api.github.com/repos/laravel/socialite/zipball/83d7523c97c1101d288126948947891319eef800", + "reference": "83d7523c97c1101d288126948947891319eef800", "shasum": "" }, "require": { @@ -2146,7 +2145,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2025-07-23T14:16:08+00:00" + "time": "2025-10-27T15:36:41+00:00" }, { "name": "laravel/tinker", @@ -3393,25 +3392,25 @@ }, { "name": "nette/schema", - "version": "v1.3.2", + "version": "v1.3.3", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "da801d52f0354f70a638673c4a0f04e16529431d" + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", - "reference": "da801d52f0354f70a638673c4a0f04e16529431d", + "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", "shasum": "" }, "require": { "nette/utils": "^4.0", - "php": "8.1 - 8.4" + "php": "8.1 - 8.5" }, "require-dev": { "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^1.0", + "phpstan/phpstan-nette": "^2.0@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -3421,6 +3420,9 @@ } }, "autoload": { + "psr-4": { + "Nette\\": "src" + }, "classmap": [ "src/" ] @@ -3449,9 +3451,9 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.2" + "source": "https://github.com/nette/schema/tree/v1.3.3" }, - "time": "2024-10-06T23:10:23+00:00" + "time": "2025-10-30T22:57:59+00:00" }, { "name": "nette/utils", @@ -3544,16 +3546,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.1", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + "reference": "3a454ca033b9e06b63282ce19562e892747449bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb", "shasum": "" }, "require": { @@ -3596,9 +3598,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" }, - "time": "2025-08-13T20:13:15+00:00" + "time": "2025-10-21T19:32:17+00:00" }, { "name": "nunomaduro/termwind", @@ -4584,16 +4586,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.13", + "version": "v0.12.14", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "d86c2f750e72017a5cdb1b9f1cef468a5cbacd1e" + "reference": "95c29b3756a23855a30566b745d218bee690bef2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/d86c2f750e72017a5cdb1b9f1cef468a5cbacd1e", - "reference": "d86c2f750e72017a5cdb1b9f1cef468a5cbacd1e", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/95c29b3756a23855a30566b745d218bee690bef2", + "reference": "95c29b3756a23855a30566b745d218bee690bef2", "shasum": "" }, "require": { @@ -4614,7 +4616,6 @@ "suggest": { "composer/class-map-generator": "Improved tab completion performance with better class discovery.", "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", - "ext-pdo-sqlite": "The doc command requires SQLite to work.", "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." }, "bin": [ @@ -4658,9 +4659,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.13" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.14" }, - "time": "2025-10-20T22:48:29+00:00" + "time": "2025-10-27T17:15:31+00:00" }, { "name": "ralouphie/getallheaders", @@ -5412,16 +5413,16 @@ }, { "name": "symfony/console", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db" + "reference": "cdb80fa5869653c83cfe1a9084a673b6daf57ea7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", - "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", + "url": "https://api.github.com/repos/symfony/console/zipball/cdb80fa5869653c83cfe1a9084a673b6daf57ea7", + "reference": "cdb80fa5869653c83cfe1a9084a673b6daf57ea7", "shasum": "" }, "require": { @@ -5486,7 +5487,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.4" + "source": "https://github.com/symfony/console/tree/v7.3.5" }, "funding": [ { @@ -5506,7 +5507,7 @@ "type": "tidelift" } ], - "time": "2025-09-22T15:31:00+00:00" + "time": "2025-10-14T15:46:26+00:00" }, { "name": "symfony/css-selector", @@ -5883,16 +5884,16 @@ }, { "name": "symfony/finder", - "version": "v7.3.2", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + "reference": "9f696d2f1e340484b4683f7853b273abff94421f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", + "reference": "9f696d2f1e340484b4683f7853b273abff94421f", "shasum": "" }, "require": { @@ -5927,7 +5928,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.2" + "source": "https://github.com/symfony/finder/tree/v7.3.5" }, "funding": [ { @@ -5947,20 +5948,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-10-15T18:45:57+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6" + "reference": "ce31218c7cac92eab280762c4375fb70a6f4f897" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c061c7c18918b1b64268771aad04b40be41dd2e6", - "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ce31218c7cac92eab280762c4375fb70a6f4f897", + "reference": "ce31218c7cac92eab280762c4375fb70a6f4f897", "shasum": "" }, "require": { @@ -6010,7 +6011,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.3.4" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.5" }, "funding": [ { @@ -6030,20 +6031,20 @@ "type": "tidelift" } ], - "time": "2025-09-16T08:38:17+00:00" + "time": "2025-10-24T21:42:11+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "b796dffea7821f035047235e076b60ca2446e3cf" + "reference": "24fd3f123532e26025f49f1abefcc01a69ef15ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b796dffea7821f035047235e076b60ca2446e3cf", - "reference": "b796dffea7821f035047235e076b60ca2446e3cf", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/24fd3f123532e26025f49f1abefcc01a69ef15ab", + "reference": "24fd3f123532e26025f49f1abefcc01a69ef15ab", "shasum": "" }, "require": { @@ -6128,7 +6129,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.3.4" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.5" }, "funding": [ { @@ -6148,20 +6149,20 @@ "type": "tidelift" } ], - "time": "2025-09-27T12:32:17+00:00" + "time": "2025-10-28T10:19:01+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "ab97ef2f7acf0216955f5845484235113047a31d" + "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/ab97ef2f7acf0216955f5845484235113047a31d", - "reference": "ab97ef2f7acf0216955f5845484235113047a31d", + "url": "https://api.github.com/repos/symfony/mailer/zipball/fd497c45ba9c10c37864e19466b090dcb60a50ba", + "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba", "shasum": "" }, "require": { @@ -6212,7 +6213,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.4" + "source": "https://github.com/symfony/mailer/tree/v7.3.5" }, "funding": [ { @@ -6232,7 +6233,7 @@ "type": "tidelift" } ], - "time": "2025-09-17T05:51:54+00:00" + "time": "2025-10-24T14:27:20+00:00" }, { "name": "symfony/mime", @@ -7728,16 +7729,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb" + "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", - "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d", + "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d", "shasum": "" }, "require": { @@ -7791,7 +7792,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.5" }, "funding": [ { @@ -7811,7 +7812,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-09-27T09:00:46+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8025,64 +8026,6 @@ } ], "time": "2024-11-21T01:49:47+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.12.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "541057574806f942c94662b817a50f63f7345360" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/541057574806f942c94662b817a50f63f7345360", - "reference": "541057574806f942c94662b817a50f63f7345360", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^7.2 || ^8.0" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.0" - }, - "time": "2025-10-20T12:43:39+00:00" } ], "packages-dev": [ @@ -9227,16 +9170,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.42", + "version": "11.5.43", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c" + "reference": "c6b89b6cf4324a8b4cb86e1f5dfdd6c9e0371924" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c", - "reference": "1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c6b89b6cf4324a8b4cb86e1f5dfdd6c9e0371924", + "reference": "c6b89b6cf4324a8b4cb86e1f5dfdd6c9e0371924", "shasum": "" }, "require": { @@ -9308,7 +9251,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.42" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.43" }, "funding": [ { @@ -9332,7 +9275,7 @@ "type": "tidelift" } ], - "time": "2025-09-28T12:09:13+00:00" + "time": "2025-10-30T08:39:39+00:00" }, { "name": "sebastian/cli-parser", From d40a68b41142e4d65dd5fa02e36c01f41e88403d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 2 Nov 2025 14:29:00 +0000 Subject: [PATCH 024/398] API: Re-ordered routes, Improved navigation Updated route order to follow some kind of logic. Updated scrolling sidebar to not be so cut-off in various scenarios. Added new nav helper to quick jump to specific API models. Closes #5865 --- resources/js/components/api-nav.ts | 32 +++++++ resources/js/components/index.ts | 1 + resources/sass/_blocks.scss | 16 +++- resources/views/api-docs/index.blade.php | 65 ++++++++----- routes/api.php | 112 ++++++++++++----------- 5 files changed, 145 insertions(+), 81 deletions(-) create mode 100644 resources/js/components/api-nav.ts diff --git a/resources/js/components/api-nav.ts b/resources/js/components/api-nav.ts new file mode 100644 index 00000000000..a84c1cb330b --- /dev/null +++ b/resources/js/components/api-nav.ts @@ -0,0 +1,32 @@ +import {Component} from "./component"; + +export class ApiNav extends Component { + private select!: HTMLSelectElement; + private sidebar!: HTMLElement; + private body!: HTMLElement; + + setup() { + this.select = this.$refs.select as HTMLSelectElement; + this.sidebar = this.$refs.sidebar; + this.body = this.$el.ownerDocument.documentElement; + this.select.addEventListener('change', () => { + const section = this.select.value; + const sidebarTarget = document.getElementById(`sidebar-header-${section}`); + const contentTarget = document.getElementById(`section-${section}`); + if (sidebarTarget && contentTarget) { + + const sidebarPos = sidebarTarget.getBoundingClientRect().top - this.sidebar.getBoundingClientRect().top + this.sidebar.scrollTop; + this.sidebar.scrollTo({ + top: sidebarPos - 120, + behavior: 'smooth', + }); + + const bodyPos = contentTarget.getBoundingClientRect().top + this.body.scrollTop; + this.body.scrollTo({ + top: bodyPos - 20, + behavior: 'smooth', + }); + } + }); + } +} \ No newline at end of file diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 63e1ad0dbf7..736d93f0595 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -1,6 +1,7 @@ export {AddRemoveRows} from './add-remove-rows'; export {AjaxDeleteRow} from './ajax-delete-row'; export {AjaxForm} from './ajax-form'; +export {ApiNav} from './api-nav'; export {Attachments} from './attachments'; export {AttachmentsList} from './attachments-list'; export {AutoSuggest} from './auto-suggest'; diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index 8c248caee14..4857c2ceee5 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -274,7 +274,19 @@ .sticky-sidebar { position: sticky; - top: vars.$m; - max-height: calc(100vh - #{vars.$m}); + top: 0; + padding-left: 2px; + max-height: calc(100vh); overflow-y: auto; + .sticky-sidebar-header { + position: sticky; + top: 0; + background: #F2F2F2; + background: linear-gradient(180deg,rgba(242, 242, 242, 1) 66%, rgba(242, 242, 242, 0) 100%); + z-index: 4; + } +} +.dark-mode .sticky-sidebar-header { + background: #111; + background: linear-gradient(180deg,rgba(17, 17, 17, 1) 66%, rgba(17, 17, 17, 0) 100%); } diff --git a/resources/views/api-docs/index.blade.php b/resources/views/api-docs/index.blade.php index 84c6d21acfb..c331f070784 100644 --- a/resources/views/api-docs/index.blade.php +++ b/resources/views/api-docs/index.blade.php @@ -2,48 +2,65 @@ @section('body') -
+
-
@include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous]) From 057d7be0bc7eb7bc6b62136da6e98c199f82f12f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 8 Feb 2026 17:03:48 +0000 Subject: [PATCH 127/398] Views: Made index/show sidebars a lot more modular Split out each sidebar block into their own template for easier customization of those elements, and less code to manage when overriding the parent show/index views. --- resources/views/books/index.blade.php | 55 +----- .../index-sidebar-section-actions.blade.php | 25 +++ .../parts/index-sidebar-section-new.blade.php | 8 + .../index-sidebar-section-popular.blade.php | 8 + .../index-sidebar-section-recents.blade.php | 6 + .../show-sidebar-section-actions.blade.php | 61 +++++++ .../show-sidebar-section-activity.blade.php | 6 + .../show-sidebar-section-details.blade.php | 21 +++ .../show-sidebar-section-shelves.blade.php | 6 + .../parts/show-sidebar-section-tags.blade.php | 5 + resources/views/books/show.blade.php | 110 +----------- .../show-sidebar-section-actions.blade.php | 65 ++++++++ .../show-sidebar-section-details.blade.php | 38 +++++ .../parts/show-sidebar-section-tags.blade.php | 5 + resources/views/chapters/show.blade.php | 116 +------------ .../show-sidebar-section-actions.blade.php | 57 +++++++ ...show-sidebar-section-attachments.blade.php | 8 + .../show-sidebar-section-details.blade.php | 61 +++++++ .../show-sidebar-section-page-nav.blade.php | 15 ++ .../parts/show-sidebar-section-tags.blade.php | 5 + resources/views/pages/show.blade.php | 156 +----------------- resources/views/shelves/index.blade.php | 48 +----- .../index-sidebar-section-actions.blade.php | 18 ++ .../parts/index-sidebar-section-new.blade.php | 8 + .../index-sidebar-section-popular.blade.php | 8 + .../index-sidebar-section-recents.blade.php | 6 + .../show-sidebar-section-actions.blade.php | 43 +++++ .../show-sidebar-section-activity.blade.php | 6 + .../show-sidebar-section-details.blade.php | 21 +++ .../parts/show-sidebar-section-tags.blade.php | 5 + resources/views/shelves/show.blade.php | 82 +-------- 31 files changed, 540 insertions(+), 542 deletions(-) create mode 100644 resources/views/books/parts/index-sidebar-section-actions.blade.php create mode 100644 resources/views/books/parts/index-sidebar-section-new.blade.php create mode 100644 resources/views/books/parts/index-sidebar-section-popular.blade.php create mode 100644 resources/views/books/parts/index-sidebar-section-recents.blade.php create mode 100644 resources/views/books/parts/show-sidebar-section-actions.blade.php create mode 100644 resources/views/books/parts/show-sidebar-section-activity.blade.php create mode 100644 resources/views/books/parts/show-sidebar-section-details.blade.php create mode 100644 resources/views/books/parts/show-sidebar-section-shelves.blade.php create mode 100644 resources/views/books/parts/show-sidebar-section-tags.blade.php create mode 100644 resources/views/chapters/parts/show-sidebar-section-actions.blade.php create mode 100644 resources/views/chapters/parts/show-sidebar-section-details.blade.php create mode 100644 resources/views/chapters/parts/show-sidebar-section-tags.blade.php create mode 100644 resources/views/pages/parts/show-sidebar-section-actions.blade.php create mode 100644 resources/views/pages/parts/show-sidebar-section-attachments.blade.php create mode 100644 resources/views/pages/parts/show-sidebar-section-details.blade.php create mode 100644 resources/views/pages/parts/show-sidebar-section-page-nav.blade.php create mode 100644 resources/views/pages/parts/show-sidebar-section-tags.blade.php create mode 100644 resources/views/shelves/parts/index-sidebar-section-actions.blade.php create mode 100644 resources/views/shelves/parts/index-sidebar-section-new.blade.php create mode 100644 resources/views/shelves/parts/index-sidebar-section-popular.blade.php create mode 100644 resources/views/shelves/parts/index-sidebar-section-recents.blade.php create mode 100644 resources/views/shelves/parts/show-sidebar-section-actions.blade.php create mode 100644 resources/views/shelves/parts/show-sidebar-section-activity.blade.php create mode 100644 resources/views/shelves/parts/show-sidebar-section-details.blade.php create mode 100644 resources/views/shelves/parts/show-sidebar-section-tags.blade.php diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 52d23241a6f..660c008dfb1 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -5,58 +5,11 @@ @stop @section('left') - @if($recents) -
-
{{ trans('entities.recently_viewed') }}
- @include('entities.list', ['entities' => $recents, 'style' => 'compact']) -
- @endif - - - -
-
{{ trans('entities.books_new') }}
- @if(count($new) > 0) - @include('entities.list', ['entities' => $new, 'style' => 'compact']) - @else -

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

- @endif -
+ @include('books.parts.index-sidebar-section-recents', ['recents' => $recents]) + @include('books.parts.index-sidebar-section-popular', ['popular' => $popular]) + @include('books.parts.index-sidebar-section-new', ['new' => $new]) @stop @section('right') - -
-
{{ trans('common.actions') }}
- -
- + @include('books.parts.index-sidebar-section-actions', ['view' => $view]) @stop diff --git a/resources/views/books/parts/index-sidebar-section-actions.blade.php b/resources/views/books/parts/index-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..8f8b254c819 --- /dev/null +++ b/resources/views/books/parts/index-sidebar-section-actions.blade.php @@ -0,0 +1,25 @@ +
+
{{ trans('common.actions') }}
+ +
\ No newline at end of file diff --git a/resources/views/books/parts/index-sidebar-section-new.blade.php b/resources/views/books/parts/index-sidebar-section-new.blade.php new file mode 100644 index 00000000000..a9aa52c5910 --- /dev/null +++ b/resources/views/books/parts/index-sidebar-section-new.blade.php @@ -0,0 +1,8 @@ +
+
{{ trans('entities.books_new') }}
+ @if(count($new) > 0) + @include('entities.list', ['entities' => $new, 'style' => 'compact']) + @else +

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

+ @endif +
\ No newline at end of file diff --git a/resources/views/books/parts/index-sidebar-section-popular.blade.php b/resources/views/books/parts/index-sidebar-section-popular.blade.php new file mode 100644 index 00000000000..030c75eb9a7 --- /dev/null +++ b/resources/views/books/parts/index-sidebar-section-popular.blade.php @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/resources/views/books/parts/index-sidebar-section-recents.blade.php b/resources/views/books/parts/index-sidebar-section-recents.blade.php new file mode 100644 index 00000000000..f1a68ba4f99 --- /dev/null +++ b/resources/views/books/parts/index-sidebar-section-recents.blade.php @@ -0,0 +1,6 @@ +@if($recents) +
+
{{ trans('entities.recently_viewed') }}
+ @include('entities.list', ['entities' => $recents, 'style' => 'compact']) +
+@endif \ No newline at end of file diff --git a/resources/views/books/parts/show-sidebar-section-actions.blade.php b/resources/views/books/parts/show-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..8e5b5c4d74a --- /dev/null +++ b/resources/views/books/parts/show-sidebar-section-actions.blade.php @@ -0,0 +1,61 @@ +
+
{{ trans('common.actions') }}
+ +
\ No newline at end of file diff --git a/resources/views/books/parts/show-sidebar-section-activity.blade.php b/resources/views/books/parts/show-sidebar-section-activity.blade.php new file mode 100644 index 00000000000..c1c5c1d3ecf --- /dev/null +++ b/resources/views/books/parts/show-sidebar-section-activity.blade.php @@ -0,0 +1,6 @@ +@if(count($activity) > 0) +
+
{{ trans('entities.recent_activity') }}
+ @include('common.activity-list', ['activity' => $activity]) +
+@endif \ No newline at end of file diff --git a/resources/views/books/parts/show-sidebar-section-details.blade.php b/resources/views/books/parts/show-sidebar-section-details.blade.php new file mode 100644 index 00000000000..709d0ffd9a1 --- /dev/null +++ b/resources/views/books/parts/show-sidebar-section-details.blade.php @@ -0,0 +1,21 @@ +
+
{{ trans('common.details') }}
+ +
\ No newline at end of file diff --git a/resources/views/books/parts/show-sidebar-section-shelves.blade.php b/resources/views/books/parts/show-sidebar-section-shelves.blade.php new file mode 100644 index 00000000000..9de9b95c62e --- /dev/null +++ b/resources/views/books/parts/show-sidebar-section-shelves.blade.php @@ -0,0 +1,6 @@ +@if(count($bookParentShelves) > 0) +
+
{{ trans('entities.shelves') }}
+ @include('entities.list', ['entities' => $bookParentShelves, 'style' => 'compact']) +
+@endif \ No newline at end of file diff --git a/resources/views/books/parts/show-sidebar-section-tags.blade.php b/resources/views/books/parts/show-sidebar-section-tags.blade.php new file mode 100644 index 00000000000..440a780c814 --- /dev/null +++ b/resources/views/books/parts/show-sidebar-section-tags.blade.php @@ -0,0 +1,5 @@ +@if($book->tags->count() > 0) +
+ @include('entities.tag-list', ['entity' => $book]) +
+@endif \ No newline at end of file diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index d510c8fd560..4eb83164f4d 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -67,114 +67,14 @@ @stop @section('right') -
-
{{ trans('common.details') }}
- -
- -
-
{{ trans('common.actions') }}
- -
- + @include('books.parts.show-sidebar-section-details', ['book' => $book, 'watchOptions' => $watchOptions]) + @include('books.parts.show-sidebar-section-actions', ['book' => $book, 'watchOptions' => $watchOptions]) @stop @section('left') - @include('entities.search-form', ['label' => trans('entities.books_search_this')]) - - @if($book->tags->count() > 0) -
- @include('entities.tag-list', ['entity' => $book]) -
- @endif - - @if(count($bookParentShelves) > 0) -
-
{{ trans('entities.shelves') }}
- @include('entities.list', ['entities' => $bookParentShelves, 'style' => 'compact']) -
- @endif - - @if(count($activity) > 0) -
-
{{ trans('entities.recent_activity') }}
- @include('common.activity-list', ['activity' => $activity]) -
- @endif + @include('books.parts.show-sidebar-section-tags', ['book' => $book]) + @include('books.parts.show-sidebar-section-shelves', ['bookParentShelves' => $bookParentShelves]) + @include('books.parts.show-sidebar-section-activity', ['activity' => $activity]) @stop diff --git a/resources/views/chapters/parts/show-sidebar-section-actions.blade.php b/resources/views/chapters/parts/show-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..55df999a22c --- /dev/null +++ b/resources/views/chapters/parts/show-sidebar-section-actions.blade.php @@ -0,0 +1,65 @@ +
+
{{ trans('common.actions') }}
+ +
\ No newline at end of file diff --git a/resources/views/chapters/parts/show-sidebar-section-details.blade.php b/resources/views/chapters/parts/show-sidebar-section-details.blade.php new file mode 100644 index 00000000000..a424b8d3fac --- /dev/null +++ b/resources/views/chapters/parts/show-sidebar-section-details.blade.php @@ -0,0 +1,38 @@ +
+
{{ trans('common.details') }}
+ +
\ No newline at end of file diff --git a/resources/views/chapters/parts/show-sidebar-section-tags.blade.php b/resources/views/chapters/parts/show-sidebar-section-tags.blade.php new file mode 100644 index 00000000000..d28ff63833e --- /dev/null +++ b/resources/views/chapters/parts/show-sidebar-section-tags.blade.php @@ -0,0 +1,5 @@ +@if($chapter->tags->count() > 0) +
+ @include('entities.tag-list', ['entity' => $chapter]) +
+@endif \ No newline at end of file diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index 585bf8a3b8d..7ef877661c0 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -63,123 +63,13 @@ @stop @section('right') - -
-
{{ trans('common.details') }}
- -
- -
-
{{ trans('common.actions') }}
- -
+ @include('chapters.parts.show-sidebar-section-details', ['chapter' => $chapter, 'book' => $book, 'watchOptions' => $watchOptions]) + @include('chapters.parts.show-sidebar-section-actions', ['chapter' => $chapter, 'watchOptions' => $watchOptions]) @stop @section('left') - @include('entities.search-form', ['label' => trans('entities.chapters_search_this')]) - - @if($chapter->tags->count() > 0) -
- @include('entities.tag-list', ['entity' => $chapter]) -
- @endif - + @include('chapters.parts.show-sidebar-section-tags', ['chapter' => $chapter]) @include('entities.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree]) @stop diff --git a/resources/views/pages/parts/show-sidebar-section-actions.blade.php b/resources/views/pages/parts/show-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..ae115b69e23 --- /dev/null +++ b/resources/views/pages/parts/show-sidebar-section-actions.blade.php @@ -0,0 +1,57 @@ +
+
{{ trans('common.actions') }}
+ + + +
\ No newline at end of file diff --git a/resources/views/pages/parts/show-sidebar-section-attachments.blade.php b/resources/views/pages/parts/show-sidebar-section-attachments.blade.php new file mode 100644 index 00000000000..9757240159b --- /dev/null +++ b/resources/views/pages/parts/show-sidebar-section-attachments.blade.php @@ -0,0 +1,8 @@ +@if($page->attachments->count() > 0) +
+
{{ trans('entities.pages_attachments') }}
+
+ @include('attachments.list', ['attachments' => $page->attachments]) +
+
+@endif \ No newline at end of file diff --git a/resources/views/pages/parts/show-sidebar-section-details.blade.php b/resources/views/pages/parts/show-sidebar-section-details.blade.php new file mode 100644 index 00000000000..391f30ce4cc --- /dev/null +++ b/resources/views/pages/parts/show-sidebar-section-details.blade.php @@ -0,0 +1,61 @@ +
+
{{ trans('common.details') }}
+ +
\ No newline at end of file diff --git a/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php b/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php new file mode 100644 index 00000000000..88db87e6483 --- /dev/null +++ b/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php @@ -0,0 +1,15 @@ +@if(isset($pageNav) && count($pageNav)) + +@endif \ No newline at end of file diff --git a/resources/views/pages/parts/show-sidebar-section-tags.blade.php b/resources/views/pages/parts/show-sidebar-section-tags.blade.php new file mode 100644 index 00000000000..354da627c55 --- /dev/null +++ b/resources/views/pages/parts/show-sidebar-section-tags.blade.php @@ -0,0 +1,5 @@ +@if($page->tags->count() > 0) +
+ @include('entities.tag-list', ['entity' => $page]) +
+@endif \ No newline at end of file diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index 3338646a509..d34bfc6aee5 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -36,159 +36,13 @@ class="page-content clearfix"> @stop @section('left') - - @if($page->tags->count() > 0) -
- @include('entities.tag-list', ['entity' => $page]) -
- @endif - - @if ($page->attachments->count() > 0) -
-
{{ trans('entities.pages_attachments') }}
-
- @include('attachments.list', ['attachments' => $page->attachments]) -
-
- @endif - - @if (isset($pageNav) && count($pageNav)) - - @endif - + @include('pages.parts.show-sidebar-section-tags', ['page' => $page]) + @include('pages.parts.show-sidebar-section-attachments', ['page' => $page]) + @include('pages.parts.show-sidebar-section-page-nav', ['pageNav' => $pageNav]) @include('entities.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree]) @stop @section('right') -
-
{{ trans('common.details') }}
- -
- -
-
{{ trans('common.actions') }}
- - - -
+ @include('pages.parts.show-sidebar-section-details', ['page' => $page, 'watchOptions' => $watchOptions, 'book' => $book]) + @include('pages.parts.show-sidebar-section-actions', ['page' => $page, 'watchOptions' => $watchOptions]) @stop diff --git a/resources/views/shelves/index.blade.php b/resources/views/shelves/index.blade.php index bb7c57e0fde..70357068d7e 100644 --- a/resources/views/shelves/index.blade.php +++ b/resources/views/shelves/index.blade.php @@ -5,51 +5,11 @@ @stop @section('right') - -
-
{{ trans('common.actions') }}
- -
- + @include('shelves.parts.index-sidebar-section-actions', ['view' => $view]) @stop @section('left') - @if($recents) -
-
{{ trans('entities.recently_viewed') }}
- @include('entities.list', ['entities' => $recents, 'style' => 'compact']) -
- @endif - - - -
-
{{ trans('entities.shelves_new') }}
- @if(count($new) > 0) - @include('entities.list', ['entities' => $new, 'style' => 'compact']) - @else -

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

- @endif -
+ @include('shelves.parts.index-sidebar-section-recents', ['recents' => $recents]) + @include('shelves.parts.index-sidebar-section-popular', ['popular' => $popular]) + @include('shelves.parts.index-sidebar-section-new', ['new' => $new]) @stop \ No newline at end of file diff --git a/resources/views/shelves/parts/index-sidebar-section-actions.blade.php b/resources/views/shelves/parts/index-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..d5cdb4056a4 --- /dev/null +++ b/resources/views/shelves/parts/index-sidebar-section-actions.blade.php @@ -0,0 +1,18 @@ +
+
{{ trans('common.actions') }}
+ +
\ No newline at end of file diff --git a/resources/views/shelves/parts/index-sidebar-section-new.blade.php b/resources/views/shelves/parts/index-sidebar-section-new.blade.php new file mode 100644 index 00000000000..602f60ebeaa --- /dev/null +++ b/resources/views/shelves/parts/index-sidebar-section-new.blade.php @@ -0,0 +1,8 @@ +
+
{{ trans('entities.shelves_new') }}
+ @if(count($new) > 0) + @include('entities.list', ['entities' => $new, 'style' => 'compact']) + @else +

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

+ @endif +
\ No newline at end of file diff --git a/resources/views/shelves/parts/index-sidebar-section-popular.blade.php b/resources/views/shelves/parts/index-sidebar-section-popular.blade.php new file mode 100644 index 00000000000..956321c5e01 --- /dev/null +++ b/resources/views/shelves/parts/index-sidebar-section-popular.blade.php @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/resources/views/shelves/parts/index-sidebar-section-recents.blade.php b/resources/views/shelves/parts/index-sidebar-section-recents.blade.php new file mode 100644 index 00000000000..f1a68ba4f99 --- /dev/null +++ b/resources/views/shelves/parts/index-sidebar-section-recents.blade.php @@ -0,0 +1,6 @@ +@if($recents) +
+
{{ trans('entities.recently_viewed') }}
+ @include('entities.list', ['entities' => $recents, 'style' => 'compact']) +
+@endif \ No newline at end of file diff --git a/resources/views/shelves/parts/show-sidebar-section-actions.blade.php b/resources/views/shelves/parts/show-sidebar-section-actions.blade.php new file mode 100644 index 00000000000..ba92e5f703b --- /dev/null +++ b/resources/views/shelves/parts/show-sidebar-section-actions.blade.php @@ -0,0 +1,43 @@ +
+
{{ trans('common.actions') }}
+ +
\ No newline at end of file diff --git a/resources/views/shelves/parts/show-sidebar-section-activity.blade.php b/resources/views/shelves/parts/show-sidebar-section-activity.blade.php new file mode 100644 index 00000000000..c1c5c1d3ecf --- /dev/null +++ b/resources/views/shelves/parts/show-sidebar-section-activity.blade.php @@ -0,0 +1,6 @@ +@if(count($activity) > 0) +
+
{{ trans('entities.recent_activity') }}
+ @include('common.activity-list', ['activity' => $activity]) +
+@endif \ No newline at end of file diff --git a/resources/views/shelves/parts/show-sidebar-section-details.blade.php b/resources/views/shelves/parts/show-sidebar-section-details.blade.php new file mode 100644 index 00000000000..8933cc41973 --- /dev/null +++ b/resources/views/shelves/parts/show-sidebar-section-details.blade.php @@ -0,0 +1,21 @@ +
+
{{ trans('common.details') }}
+ +
\ No newline at end of file diff --git a/resources/views/shelves/parts/show-sidebar-section-tags.blade.php b/resources/views/shelves/parts/show-sidebar-section-tags.blade.php new file mode 100644 index 00000000000..265d61cd0d3 --- /dev/null +++ b/resources/views/shelves/parts/show-sidebar-section-tags.blade.php @@ -0,0 +1,5 @@ +@if($shelf->tags->count() > 0) +
+ @include('entities.tag-list', ['entity' => $shelf]) +
+@endif \ No newline at end of file diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index 9ee14f1bf4e..9d07e5da018 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -69,87 +69,13 @@ @stop @section('left') - - @if($shelf->tags->count() > 0) -
- @include('entities.tag-list', ['entity' => $shelf]) -
- @endif - -
-
{{ trans('common.details') }}
- -
- - @if(count($activity) > 0) -
-
{{ trans('entities.recent_activity') }}
- @include('common.activity-list', ['activity' => $activity]) -
- @endif + @include('shelves.parts.show-sidebar-section-tags', ['shelf' => $shelf]) + @include('shelves.parts.show-sidebar-section-details', ['shelf' => $shelf]) + @include('shelves.parts.show-sidebar-section-activity', ['activity' => $activity]) @stop @section('right') -
-
{{ trans('common.actions') }}
- -
+ @include('shelves.parts.show-sidebar-section-actions', ['shelf' => $shelf, 'view' => $view]) @stop From 10ebe53bd9c9f086efaa35eecd3656f1a201d84d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 13 Feb 2026 14:14:28 +0000 Subject: [PATCH 128/398] Page Content: Added more complex & configurable content filtering - Added new option to control parts of the filter. - Added whitelist filtering pass via HTMLPurifier. --- app/Activity/Models/Comment.php | 4 +- app/Config/app.php | 12 ++ app/Entities/Tools/EntityHtmlDescription.php | 4 +- app/Entities/Tools/PageContent.php | 24 +++- app/Theming/CustomHtmlHeadContentProvider.php | 22 +-- app/Util/HtmlContentFilter.php | 129 ++++++++++++------ app/Util/HtmlContentFilterConfig.php | 31 +++++ composer.json | 4 +- composer.lock | 123 ++++++++++++++++- storage/purifier/.gitignore | 2 + 10 files changed, 292 insertions(+), 63 deletions(-) create mode 100644 app/Util/HtmlContentFilterConfig.php create mode 100644 storage/purifier/.gitignore diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index ce05e3df35b..ab7d917729c 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -8,6 +8,7 @@ use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\OwnableInterface; use BookStack\Util\HtmlContentFilter; +use BookStack\Util\HtmlContentFilterConfig; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -82,7 +83,8 @@ public function logDescriptor(): string public function safeHtml(): string { - return HtmlContentFilter::removeActiveContentFromHtmlString($this->html ?? ''); + $filter = new HtmlContentFilter(new HtmlContentFilterConfig()); + return $filter->filterString($this->html ?? ''); } public function jointPermissions(): HasMany diff --git a/app/Config/app.php b/app/Config/app.php index 40e542d3e16..acd27e98c02 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -42,6 +42,18 @@ // Even when overridden the WYSIWYG editor may still escape script content. 'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false), + // Control the behaviour of page content filtering. + // This setting is a collection of characters which represent different available filters: + // - j - Filter out JavaScript based content + // - h - Filter out unexpected, potentially dangerous, HTML elements + // - f - Filter out unexpected form elements + // - a - Run content through a more complex allow-list filter + // This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used. + // Note: These filters are a best attempt, and may not be 100% effective. They are typically a layer used in addition to other security measures. + // TODO - Add to example env + // TODO - Remove allow_content_scripts option above + 'content_filtering' => env('CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jfha'), + // Allow server-side fetches to be performed to potentially unknown // and user-provided locations. Primarily used in exports when loading // in externally referenced assets. diff --git a/app/Entities/Tools/EntityHtmlDescription.php b/app/Entities/Tools/EntityHtmlDescription.php index b14deb257a7..6bbfb9b6651 100644 --- a/app/Entities/Tools/EntityHtmlDescription.php +++ b/app/Entities/Tools/EntityHtmlDescription.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Util\HtmlContentFilter; +use BookStack\Util\HtmlContentFilterConfig; class EntityHtmlDescription { @@ -50,7 +51,8 @@ public function getHtml(bool $raw = false): string return $html; } - return HtmlContentFilter::removeActiveContentFromHtmlString($html); + $filter = new HtmlContentFilter(new HtmlContentFilterConfig()); + return $filter->filterString($html); } public function getPlain(): string diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 5358e8f0c5b..ca06e696185 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -13,6 +13,7 @@ use BookStack\Uploads\ImageService; use BookStack\Users\Models\User; use BookStack\Util\HtmlContentFilter; +use BookStack\Util\HtmlContentFilterConfig; use BookStack\Util\HtmlDocument; use BookStack\Util\WebSafeMimeSniffer; use Closure; @@ -317,11 +318,28 @@ public function render(bool $blankIncludes = false): string $this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap); } - if (!config('app.allow_content_scripts')) { - HtmlContentFilter::removeActiveContentFromDocument($doc); + $cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml()); + $cached = cache()->get($cacheKey, null); + if ($cached !== null) { + return $cached; } - return $doc->getBodyInnerHtml(); + $filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); + $filter = new HtmlContentFilter($filterConfig); + $filtered = $filter->filterDocument($doc); + + $cacheTime = 86400 * 7; // 1 week + cache()->put($cacheKey, $filtered, $cacheTime); + + return $filtered; + } + + protected function getContentCacheKey(string $html): string + { + $contentHash = md5($html); + $contentId = $this->page->id; + $contentTime = $this->page->updated_at->timestamp; + return "page-content-cache::{$contentId}::{$contentTime}::{$contentHash}"; } /** diff --git a/app/Theming/CustomHtmlHeadContentProvider.php b/app/Theming/CustomHtmlHeadContentProvider.php index e0cf5b3b5c7..dab30606c34 100644 --- a/app/Theming/CustomHtmlHeadContentProvider.php +++ b/app/Theming/CustomHtmlHeadContentProvider.php @@ -4,25 +4,16 @@ use BookStack\Util\CspService; use BookStack\Util\HtmlContentFilter; +use BookStack\Util\HtmlContentFilterConfig; use BookStack\Util\HtmlNonceApplicator; use Illuminate\Contracts\Cache\Repository as Cache; class CustomHtmlHeadContentProvider { - /** - * @var CspService - */ - protected $cspService; - - /** - * @var Cache - */ - protected $cache; - - public function __construct(CspService $cspService, Cache $cache) - { - $this->cspService = $cspService; - $this->cache = $cache; + public function __construct( + protected CspService $cspService, + protected Cache $cache + ) { } /** @@ -50,7 +41,8 @@ public function forExport(): string $hash = md5($content); return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) { - return HtmlContentFilter::removeActiveContentFromHtmlString($content); + $config = new HtmlContentFilterConfig(filterOutNonContentElements: false); + return (new HtmlContentFilter($config))->filterString($content); }); } diff --git a/app/Util/HtmlContentFilter.php b/app/Util/HtmlContentFilter.php index ad5bf8c5fd3..842e4246736 100644 --- a/app/Util/HtmlContentFilter.php +++ b/app/Util/HtmlContentFilter.php @@ -5,15 +5,53 @@ use DOMAttr; use DOMElement; use DOMNodeList; +use HTMLPurifier; +use HTMLPurifier_HTML5Config; class HtmlContentFilter { - /** - * Remove all active content from the given HTML document. - * This aims to cover anything which can dynamically deal with, or send, data - * like any JavaScript actions or form content. - */ - public static function removeActiveContentFromDocument(HtmlDocument $doc): void + public function __construct( + protected HtmlContentFilterConfig $config + ) { + } + + public function filterDocument(HtmlDocument $doc): string + { + if ($this->config->filterOutJavaScript) { + $this->filterOutScriptsFromDocument($doc); + } + if ($this->config->filterOutFormElements) { + $this->filterOutFormElementsFromDocument($doc); + } + if ($this->config->filterOutBadHtmlElements) { + $this->filterOutBadHtmlElementsFromDocument($doc); + } + if ($this->config->filterOutNonContentElements) { + $this->filterOutNonContentElementsFromDocument($doc); + } + + $filtered = $doc->getBodyInnerHtml(); + if ($this->config->useAllowListFilter) { + $filtered = $this->applyAllowListFiltering($filtered); + } + + return $filtered; + } + + public function filterString(string $html): string + { + return $this->filterDocument(new HtmlDocument($html)); + } + + protected function applyAllowListFiltering(string $html): string + { + $config = HTMLPurifier_HTML5Config::createDefault(); + $config->set('Cache.SerializerPath', storage_path('purifier')); + $purifier = new HTMLPurifier($config); + return $purifier->purify($html); + } + + protected function filterOutScriptsFromDocument(HtmlDocument $doc): void { // Remove standard script tags $scriptElems = $doc->queryXPath('//script'); @@ -27,10 +65,6 @@ public static function removeActiveContentFromDocument(HtmlDocument $doc): void $badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']'); static::removeNodes($badForms); - // Remove meta tag to prevent external redirects - $metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']'); - static::removeNodes($metaTags); - // Remove data or JavaScript iFrames $badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]'); static::removeNodes($badIframes); @@ -49,7 +83,10 @@ public static function removeActiveContentFromDocument(HtmlDocument $doc): void // Remove 'on*' attributes $onAttributes = $doc->queryXPath('//@*[starts-with(name(), \'on\')]'); static::removeAttributes($onAttributes); + } + protected function filterOutFormElementsFromDocument(HtmlDocument $doc): void + { // Remove form elements $formElements = ['form', 'fieldset', 'button', 'textarea', 'select']; foreach ($formElements as $formElement) { @@ -75,41 +112,21 @@ public static function removeActiveContentFromDocument(HtmlDocument $doc): void } } - /** - * Remove active content from the given HTML string. - * This aims to cover anything which can dynamically deal with, or send, data - * like any JavaScript actions or form content. - */ - public static function removeActiveContentFromHtmlString(string $html): string + protected function filterOutBadHtmlElementsFromDocument(HtmlDocument $doc): void { - if (empty($html)) { - return $html; - } - - $doc = new HtmlDocument($html); - static::removeActiveContentFromDocument($doc); - - return $doc->getBodyInnerHtml(); - } - - /** - * Alias using the old method name to avoid potential compatibility breaks during patch release. - * To remove in future feature release. - * @deprecated Use removeActiveContentFromDocument instead. - */ - public static function removeScriptsFromDocument(HtmlDocument $doc): void - { - static::removeActiveContentFromDocument($doc); + // Remove meta tag to prevent external redirects + $metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']'); + static::removeNodes($metaTags); } - /** - * Alias using the old method name to avoid potential compatibility breaks during patch release. - * To remove in future feature release. - * @deprecated Use removeActiveContentFromHtmlString instead. - */ - public static function removeScriptsFromHtmlString(string $html): string + protected function filterOutNonContentElementsFromDocument(HtmlDocument $doc): void { - return static::removeActiveContentFromHtmlString($html); + // Remove non-content elements + $formElements = ['link', 'style', 'meta', 'title', 'template']; + foreach ($formElements as $formElement) { + $matchingFormElements = $doc->queryXPath('//' . $formElement); + static::removeNodes($matchingFormElements); + } } /** @@ -147,4 +164,34 @@ protected static function removeAttributes(DOMNodeList $attrs): void $parentNode->removeAttribute($attrName); } } + + /** + * Alias using the old method name to avoid potential compatibility breaks during patch release. + * To remove in future feature release. + * @deprecated Use filterDocument instead. + */ + public static function removeScriptsFromDocument(HtmlDocument $doc): void + { + $config = new HtmlContentFilterConfig( + filterOutNonContentElements: false, + useAllowListFilter: false, + ); + $filter = new static($config); + $filter->filterDocument($doc); + } + + /** + * Alias using the old method name to avoid potential compatibility breaks during patch release. + * To remove in future feature release. + * @deprecated Use filterString instead. + */ + public static function removeScriptsFromHtmlString(string $html): string + { + $config = new HtmlContentFilterConfig( + filterOutNonContentElements: false, + useAllowListFilter: false, + ); + $filter = new static($config); + return $filter->filterString($html); + } } diff --git a/app/Util/HtmlContentFilterConfig.php b/app/Util/HtmlContentFilterConfig.php new file mode 100644 index 00000000000..2cb77ea5815 --- /dev/null +++ b/app/Util/HtmlContentFilterConfig.php @@ -0,0 +1,31 @@ +=5.3" + }, + "require-dev": { + "masterminds/html5": "^2.7", + "php-coveralls/php-coveralls": "^1.1|^2.1", + "phpunit/phpunit": ">=4.7 <10.0" + }, + "suggest": { + "masterminds/html5": "Required to use HTMLPurifier_Lexer_HTML5" + }, + "type": "library", + "autoload": { + "classmap": [ + "library/HTMLPurifier/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "xemlock", + "email": "xemlock@gmail.com" + } + ], + "description": "HTML5 support for HTML Purifier", + "homepage": "https://github.com/xemlock/htmlpurifier-html5", + "keywords": [ + "HTML5", + "Purifier", + "html", + "htmlpurifier", + "security", + "tidy", + "validator", + "xss" + ], + "support": { + "issues": "https://github.com/xemlock/htmlpurifier-html5/issues", + "source": "https://github.com/xemlock/htmlpurifier-html5/tree/v0.1.12" + }, + "time": "2026-02-09T21:03:14+00:00" } ], "packages-dev": [ diff --git a/storage/purifier/.gitignore b/storage/purifier/.gitignore new file mode 100644 index 00000000000..c96a04f008e --- /dev/null +++ b/storage/purifier/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file From 0f040fe8b1bbaaee4d088262aa4482a4b19b1c46 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Feb 2026 16:17:03 +0000 Subject: [PATCH 129/398] Content: Tuned HTML purifier for our use Tested it with a range of supported, including uncommon, content types and added support, or changed config, where needed. Been through docs for all HTMLPurifier options to assess what's relevant. --- app/Entities/Tools/PageContent.php | 3 +- app/Util/ConfiguredHtmlPurifier.php | 101 ++++++++++++++++++++++++++++ app/Util/HtmlContentFilter.php | 6 +- 3 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 app/Util/ConfiguredHtmlPurifier.php diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index ca06e696185..67c6e4cf6e8 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -321,12 +321,13 @@ public function render(bool $blankIncludes = false): string $cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml()); $cached = cache()->get($cacheKey, null); if ($cached !== null) { - return $cached; +// return $cached; } $filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); $filter = new HtmlContentFilter($filterConfig); $filtered = $filter->filterDocument($doc); +// $filtered = $doc->getBodyInnerHtml(); $cacheTime = 86400 * 7; // 1 week cache()->put($cacheKey, $filtered, $cacheTime); diff --git a/app/Util/ConfiguredHtmlPurifier.php b/app/Util/ConfiguredHtmlPurifier.php new file mode 100644 index 00000000000..5aab25b4745 --- /dev/null +++ b/app/Util/ConfiguredHtmlPurifier.php @@ -0,0 +1,101 @@ +setConfig($config); + + $htmlDef = $config->getDefinition('HTML', true, true); + if ($htmlDef instanceof HTMLPurifier_HTMLDefinition) { + $this->configureDefinition($htmlDef); + } + + $this->purifier = new HTMLPurifier($config); + } + + protected function setConfig(HTMLPurifier_Config $config): void + { + $config->set('Cache.SerializerPath', storage_path('purifier')); + $config->set('CSS.AllowTricky', true); + $config->set('HTML.SafeIframe', true); + $config->set('Attr.EnableID', true); + $config->set('Attr.ID.HTML5', true); + $config->set('Output.FixInnerHTML', false); + $config->set('URI.SafeIframeRegexp', '%^(http://|https://)%'); + $config->set('URI.AllowedSchemes', [ + 'http' => true, + 'https' => true, + 'mailto' => true, + 'ftp' => true, + 'nntp' => true, + 'news' => true, + 'tel' => true, + 'file' => true, + ]); + + $config->set('Cache.DefinitionImpl', null); // Disable cache during testing + } + + public function configureDefinition(HTMLPurifier_HTMLDefinition $definition): void + { + // Allow the object element + $definition->addElement( + 'object', + 'Inline', + 'Flow', + 'Common', + [ + 'data' => 'URI', + 'type' => 'Text', + 'width' => 'Length', + 'height' => 'Length', + ] + ); + + // Allow the embed element + $definition->addElement( + 'embed', + 'Inline', + 'Empty', + 'Common', + [ + 'src' => 'URI', + 'type' => 'Text', + 'width' => 'Length', + 'height' => 'Length', + ] + ); + + // Allow checkbox inputs + $definition->addElement( + 'input', + 'Formctrl', + 'Empty', + 'Common', + [ + 'checked' => 'Bool#checked', + 'disabled' => 'Bool#disabled', + 'name' => 'Text', + 'readonly' => 'Bool#readonly', + 'type' => 'Enum#checkbox', + 'value' => 'Text', + ] + ); + } + + public function purify(string $html): string + { + return $this->purifier->purify($html); + } +} diff --git a/app/Util/HtmlContentFilter.php b/app/Util/HtmlContentFilter.php index 842e4246736..79b1cdc93c4 100644 --- a/app/Util/HtmlContentFilter.php +++ b/app/Util/HtmlContentFilter.php @@ -5,8 +5,6 @@ use DOMAttr; use DOMElement; use DOMNodeList; -use HTMLPurifier; -use HTMLPurifier_HTML5Config; class HtmlContentFilter { @@ -45,9 +43,7 @@ public function filterString(string $html): string protected function applyAllowListFiltering(string $html): string { - $config = HTMLPurifier_HTML5Config::createDefault(); - $config->set('Cache.SerializerPath', storage_path('purifier')); - $purifier = new HTMLPurifier($config); + $purifier = new ConfiguredHtmlPurifier(); return $purifier->purify($html); } From 227027fc4570270395fe5dd0aa2bb8201163752a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Feb 2026 16:46:09 +0000 Subject: [PATCH 130/398] Content: Updated purifier and content caching - Updated page content cache to use app version in cache key - Moved purifier cache into framework to better work with existing expected folders. - Added app version check to purifier so that it will reset its own cache on app version change. --- app/Entities/Tools/PageContent.php | 7 +++-- app/Util/ConfiguredHtmlPurifier.php | 34 +++++++++++++++++++-- storage/{ => framework}/purifier/.gitignore | 0 3 files changed, 36 insertions(+), 5 deletions(-) rename storage/{ => framework}/purifier/.gitignore (100%) diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 67c6e4cf6e8..436c4f0bed8 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -2,6 +2,7 @@ namespace BookStack\Entities\Tools; +use BookStack\App\AppVersion; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Tools\Markdown\MarkdownToHtml; @@ -321,13 +322,12 @@ public function render(bool $blankIncludes = false): string $cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml()); $cached = cache()->get($cacheKey, null); if ($cached !== null) { -// return $cached; + return $cached; } $filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); $filter = new HtmlContentFilter($filterConfig); $filtered = $filter->filterDocument($doc); -// $filtered = $doc->getBodyInnerHtml(); $cacheTime = 86400 * 7; // 1 week cache()->put($cacheKey, $filtered, $cacheTime); @@ -340,7 +340,8 @@ protected function getContentCacheKey(string $html): string $contentHash = md5($html); $contentId = $this->page->id; $contentTime = $this->page->updated_at->timestamp; - return "page-content-cache::{$contentId}::{$contentTime}::{$contentHash}"; + $appVersion = AppVersion::get(); + return "page-content-cache::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}"; } /** diff --git a/app/Util/ConfiguredHtmlPurifier.php b/app/Util/ConfiguredHtmlPurifier.php index 5aab25b4745..d63d2ad5f3c 100644 --- a/app/Util/ConfiguredHtmlPurifier.php +++ b/app/Util/ConfiguredHtmlPurifier.php @@ -2,19 +2,29 @@ namespace BookStack\Util; +use BookStack\App\AppVersion; use HTMLPurifier; use HTMLPurifier_Config; +use HTMLPurifier_DefinitionCache_Serializer; use HTMLPurifier_HTML5Config; use HTMLPurifier_HTMLDefinition; +/** + * Provides a configured HTML Purifier instance. + * https://github.com/ezyang/htmlpurifier + * Also uses this to extend support to HTML5 elements: + * https://github.com/xemlock/htmlpurifier-html5 + */ class ConfiguredHtmlPurifier { protected HTMLPurifier $purifier; + protected static bool $cachedChecked = false; public function __construct() { $config = HTMLPurifier_HTML5Config::createDefault(); $this->setConfig($config); + $this->resetCacheIfNeeded($config); $htmlDef = $config->getDefinition('HTML', true, true); if ($htmlDef instanceof HTMLPurifier_HTMLDefinition) { @@ -24,9 +34,29 @@ public function __construct() $this->purifier = new HTMLPurifier($config); } + protected function resetCacheIfNeeded(HTMLPurifier_Config $config): void + { + if (self::$cachedChecked) { + return; + } + + $cachedForVersion = cache('htmlpurifier::cache-version'); + $appVersion = AppVersion::get(); + if ($cachedForVersion !== $appVersion) { + foreach (['HTML', 'CSS', 'URI'] as $name) { + $cache = new HTMLPurifier_DefinitionCache_Serializer($name); + $cache->flush($config); + } + cache()->set('htmlpurifier::cache-version', $appVersion); + } + + self::$cachedChecked = true; + } + protected function setConfig(HTMLPurifier_Config $config): void { - $config->set('Cache.SerializerPath', storage_path('purifier')); + $config->set('Cache.SerializerPath', storage_path('framework/purifier')); + $config->set('Core.AllowHostnameUnderscore', true); $config->set('CSS.AllowTricky', true); $config->set('HTML.SafeIframe', true); $config->set('Attr.EnableID', true); @@ -44,7 +74,7 @@ protected function setConfig(HTMLPurifier_Config $config): void 'file' => true, ]); - $config->set('Cache.DefinitionImpl', null); // Disable cache during testing + // $config->set('Cache.DefinitionImpl', null); // Disable cache during testing } public function configureDefinition(HTMLPurifier_HTMLDefinition $definition): void diff --git a/storage/purifier/.gitignore b/storage/framework/purifier/.gitignore similarity index 100% rename from storage/purifier/.gitignore rename to storage/framework/purifier/.gitignore From 035be66ebc7d4a312b5a240283a5a13da9694779 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Feb 2026 18:44:14 +0000 Subject: [PATCH 131/398] Content: Updated tests and CSP usage of content script setting Updates CSP to use new content_filtering option. Splits out content filtering tests to their own class. Updated tests where needed to adapt to changes. --- app/Entities/Tools/PageContent.php | 2 +- app/Theming/CustomHtmlHeadContentProvider.php | 2 +- app/Util/ConfiguredHtmlPurifier.php | 2 +- app/Util/CspService.php | 9 +- tests/Entity/PageContentFilteringTest.php | 353 ++++++++++++++++++ tests/Entity/PageContentTest.php | 348 +---------------- tests/SecurityHeaderTest.php | 6 +- 7 files changed, 368 insertions(+), 354 deletions(-) create mode 100644 tests/Entity/PageContentFilteringTest.php diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 436c4f0bed8..f8a0617395b 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -339,7 +339,7 @@ protected function getContentCacheKey(string $html): string { $contentHash = md5($html); $contentId = $this->page->id; - $contentTime = $this->page->updated_at->timestamp; + $contentTime = $this->page->updated_at?->timestamp ?? time(); $appVersion = AppVersion::get(); return "page-content-cache::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}"; } diff --git a/app/Theming/CustomHtmlHeadContentProvider.php b/app/Theming/CustomHtmlHeadContentProvider.php index dab30606c34..9f794a077ba 100644 --- a/app/Theming/CustomHtmlHeadContentProvider.php +++ b/app/Theming/CustomHtmlHeadContentProvider.php @@ -41,7 +41,7 @@ public function forExport(): string $hash = md5($content); return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) { - $config = new HtmlContentFilterConfig(filterOutNonContentElements: false); + $config = new HtmlContentFilterConfig(filterOutNonContentElements: false, useAllowListFilter: false); return (new HtmlContentFilter($config))->filterString($content); }); } diff --git a/app/Util/ConfiguredHtmlPurifier.php b/app/Util/ConfiguredHtmlPurifier.php index d63d2ad5f3c..014b2a3bf2b 100644 --- a/app/Util/ConfiguredHtmlPurifier.php +++ b/app/Util/ConfiguredHtmlPurifier.php @@ -62,7 +62,7 @@ protected function setConfig(HTMLPurifier_Config $config): void $config->set('Attr.EnableID', true); $config->set('Attr.ID.HTML5', true); $config->set('Output.FixInnerHTML', false); - $config->set('URI.SafeIframeRegexp', '%^(http://|https://)%'); + $config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%'); $config->set('URI.AllowedSchemes', [ 'http' => true, 'https' => true, diff --git a/app/Util/CspService.php b/app/Util/CspService.php index 4262b5c98f8..466acb49148 100644 --- a/app/Util/CspService.php +++ b/app/Util/CspService.php @@ -65,7 +65,7 @@ public function allowedIFrameHostsConfigured(): bool */ protected function getScriptSrc(): string { - if (config('app.allow_content_scripts')) { + if ($this->scriptFilteringDisabled()) { return ''; } @@ -108,7 +108,7 @@ protected function getFrameSrc(): string */ protected function getObjectSrc(): string { - if (config('app.allow_content_scripts')) { + if ($this->scriptFilteringDisabled()) { return ''; } @@ -124,6 +124,11 @@ protected function getBaseUri(): string return "base-uri 'self'"; } + protected function scriptFilteringDisabled(): bool + { + return !HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'))->filterOutJavaScript; + } + protected function getAllowedIframeHosts(): array { $hosts = config('app.iframe_hosts') ?? ''; diff --git a/tests/Entity/PageContentFilteringTest.php b/tests/Entity/PageContentFilteringTest.php new file mode 100644 index 00000000000..e1295034d68 --- /dev/null +++ b/tests/Entity/PageContentFilteringTest.php @@ -0,0 +1,353 @@ +asEditor(); + $page = $this->entities->page(); + $script = 'abc123abc123'; + $page->html = "escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertDontSee($script, false); + $pageView->assertSee('abc123abc123'); + } + + public function test_more_complex_content_script_escaping_scenarios() + { + $checks = [ + "

Some script

", + "

Some script

", + "

Some script

", + "

Some script

", + "

Some script

", + "

Some script

", + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); + } + } + + public function test_js_and_base64_src_urls_are_removed() + { + $checks = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $html = $this->withHtml($pageView); + $html->assertElementNotContains('.page-content', ''); + $html->assertElementNotContains('.page-content', 'src='); + $html->assertElementNotContains('.page-content', 'javascript:'); + $html->assertElementNotContains('.page-content', 'data:'); + $html->assertElementNotContains('.page-content', 'base64'); + } + } + + public function test_javascript_uri_links_are_removed() + { + $checks = [ + 'withHtml($pageView)->assertElementNotContains('.page-content', 'href=javascript:'); + } + } + + public function test_form_actions_with_javascript_are_removed() + { + $checks = [ + '', + 'Click me', + 'Click me', + '', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertDontSee('id="xss"', false); + $pageView->assertDontSee('action=javascript:', false); + $pageView->assertDontSee('action=JaVaScRiPt:', false); + $pageView->assertDontSee('formaction=javascript:', false); + $pageView->assertDontSee('formaction=JaVaScRiPt:', false); + } + } + + public function test_form_elements_are_removed() + { + $checks = [ + '

thisisacattofind

thisdogshouldnotbefound
', + '

thisisacattofind

', + '

thisisacattofind

', + '

thisisacattofind

', + '

thisisacattofind

thisdogshouldnotbefound
', + '

thisisacattofind

', + '

thisisacattofind

', + <<<'TESTCASE' + + + + +

thisisacattofind

+
+

thisdogshouldnotbefound

+
+ + + + +
+
+TESTCASE + + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertSee('thisisacattofind'); + $pageView->assertDontSee('thisdogshouldnotbefound'); + } + } + + public function test_form_attributes_are_removed() + { + $withinSvgSample = <<<'TESTCASE' + + + + +

thisisacattofind

+

thisisacattofind

+ + +
+
+TESTCASE; + + $checks = [ + 'formaction' => '

thisisacattofind

', + 'form' => '

thisisacattofind

', + 'formmethod' => '

thisisacattofind

', + 'formtarget' => '

thisisacattofind

', + 'FORMTARGET' => '

thisisacattofind

', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $attribute => $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertSee('thisisacattofind'); + $this->withHtml($pageView)->assertElementNotExists(".page-content [{$attribute}]"); + } + + $page->html = $withinSvgSample; + $page->save(); + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $html = $this->withHtml($pageView); + foreach ($checks as $attribute => $check) { + $pageView->assertSee('thisisacattofind'); + $html->assertElementNotExists(".page-content [{$attribute}]"); + } + } + + public function test_metadata_redirects_are_removed() + { + $checks = [ + '', + '', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); + $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); + $this->withHtml($pageView)->assertElementNotContains('.page-content', 'content='); + $this->withHtml($pageView)->assertElementNotContains('.page-content', 'external_url'); + } + } + + public function test_page_inline_on_attributes_removed_by_default() + { + $this->asEditor(); + $page = $this->entities->page(); + $script = '

Hello

'; + $page->html = "escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertDontSee($script, false); + $pageView->assertSee('

Hello

', false); + } + + public function test_more_complex_inline_on_attributes_escaping_scenarios() + { + $checks = [ + '

Hello

', + '

Hello

', + '
Lorem ipsum dolor sit amet.

Hello

', + '
Lorem ipsum dolor sit amet.

Hello

', + '
Lorem ipsum dolor sit amet.

Hello

', + '
Lorem ipsum dolor sit amet.

Hello

', + '
xss link\', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $this->withHtml($pageView)->assertElementNotContains('.page-content', 'onclick'); + } + } + + public function test_page_content_scripts_show_with_filters_disabled() + { + $this->asEditor(); + $page = $this->entities->page(); + config()->set('app.content_filtering', ''); + + $script = 'abc123abc123'; + $page->html = "no escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertSee($script, false); + $pageView->assertDontSee('abc123abc123'); + } + + public function test_svg_script_usage_is_removed() + { + $checks = [ + '', + '', + '', + '', + '', + 'XSS', + 'XSS', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $html = $this->withHtml($pageView); + $html->assertElementNotContains('.page-content', 'alert'); + $html->assertElementNotContains('.page-content', 'xlink:href'); + $html->assertElementNotContains('.page-content', 'application/xml'); + $html->assertElementNotContains('.page-content', 'javascript'); + } + } + + public function test_page_inline_on_attributes_show_with_filters_disabled() + { + $this->asEditor(); + $page = $this->entities->page(); + config()->set('app.content_filtering', ''); + + $script = '

Hello

'; + $page->html = "escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertSee($script, false); + $pageView->assertDontSee('

Hello

', false); + } +} diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index 77026113012..deae153e192 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -101,351 +101,6 @@ public function test_page_includes_to_nonexisting_pages_does_not_error() $pageResp->assertSee('Hello Barry'); } - public function test_page_content_scripts_removed_by_default() - { - $this->asEditor(); - $page = $this->entities->page(); - $script = 'abc123abc123'; - $page->html = "escape {$script}"; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $pageView->assertDontSee($script, false); - $pageView->assertSee('abc123abc123'); - } - - public function test_more_complex_content_script_escaping_scenarios() - { - $checks = [ - "

Some script

", - "

Some script

", - "

Some script

", - "

Some script

", - "

Some script

", - "

Some script

", - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); - } - } - - public function test_js_and_base64_src_urls_are_removed() - { - $checks = [ - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $html = $this->withHtml($pageView); - $html->assertElementNotContains('.page-content', ''); - $html->assertElementNotContains('.page-content', 'src='); - $html->assertElementNotContains('.page-content', 'javascript:'); - $html->assertElementNotContains('.page-content', 'data:'); - $html->assertElementNotContains('.page-content', 'base64'); - } - } - - public function test_javascript_uri_links_are_removed() - { - $checks = [ - '
withHtml($pageView)->assertElementNotContains('.page-content', 'href=javascript:'); - } - } - - public function test_form_actions_with_javascript_are_removed() - { - $checks = [ - '', - 'Click me', - 'Click me', - '', - '', - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $pageView->assertDontSee('id="xss"', false); - $pageView->assertDontSee('action=javascript:', false); - $pageView->assertDontSee('action=JaVaScRiPt:', false); - $pageView->assertDontSee('formaction=javascript:', false); - $pageView->assertDontSee('formaction=JaVaScRiPt:', false); - } - } - - public function test_form_elements_are_removed() - { - $checks = [ - '

thisisacattofind

thisdogshouldnotbefound
', - '

thisisacattofind

', - '

thisisacattofind

', - '

thisisacattofind

', - '

thisisacattofind

thisdogshouldnotbefound
', - '

thisisacattofind

', - '

thisisacattofind

', - <<<'TESTCASE' - - - - -

thisisacattofind

-
-

thisdogshouldnotbefound

-
- - - - -
-
-TESTCASE - - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $pageView->assertSee('thisisacattofind'); - $pageView->assertDontSee('thisdogshouldnotbefound'); - } - } - - public function test_form_attributes_are_removed() - { - $withinSvgSample = <<<'TESTCASE' - - - - -

thisisacattofind

-

thisisacattofind

- - -
-
-TESTCASE; - - $checks = [ - 'formaction' => '

thisisacattofind

', - 'form' => '

thisisacattofind

', - 'formmethod' => '

thisisacattofind

', - 'formtarget' => '

thisisacattofind

', - 'FORMTARGET' => '

thisisacattofind

', - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $attribute => $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $pageView->assertSee('thisisacattofind'); - $this->withHtml($pageView)->assertElementNotExists(".page-content [{$attribute}]"); - } - - $page->html = $withinSvgSample; - $page->save(); - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $html = $this->withHtml($pageView); - foreach ($checks as $attribute => $check) { - $pageView->assertSee('thisisacattofind'); - $html->assertElementNotExists(".page-content [{$attribute}]"); - } - } - - public function test_metadata_redirects_are_removed() - { - $checks = [ - '', - '', - '', - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); - $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); - $this->withHtml($pageView)->assertElementNotContains('.page-content', 'content='); - $this->withHtml($pageView)->assertElementNotContains('.page-content', 'external_url'); - } - } - - public function test_page_inline_on_attributes_removed_by_default() - { - $this->asEditor(); - $page = $this->entities->page(); - $script = '

Hello

'; - $page->html = "escape {$script}"; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $pageView->assertDontSee($script, false); - $pageView->assertSee('

Hello

', false); - } - - public function test_more_complex_inline_on_attributes_escaping_scenarios() - { - $checks = [ - '

Hello

', - '

Hello

', - '
Lorem ipsum dolor sit amet.

Hello

', - '
Lorem ipsum dolor sit amet.

Hello

', - '
Lorem ipsum dolor sit amet.

Hello

', - '
Lorem ipsum dolor sit amet.

Hello

', - '
xss link\', - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $this->withHtml($pageView)->assertElementNotContains('.page-content', 'onclick'); - } - } - - public function test_page_content_scripts_show_when_configured() - { - $this->asEditor(); - $page = $this->entities->page(); - config()->set('app.allow_content_scripts', 'true'); - - $script = 'abc123abc123'; - $page->html = "no escape {$script}"; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertSee($script, false); - $pageView->assertDontSee('abc123abc123'); - } - - public function test_svg_script_usage_is_removed() - { - $checks = [ - '', - '', - '', - '', - '', - 'XSS', - 'XSS', - '', - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $html = $this->withHtml($pageView); - $html->assertElementNotContains('.page-content', 'alert'); - $html->assertElementNotContains('.page-content', 'xlink:href'); - $html->assertElementNotContains('.page-content', 'application/xml'); - $html->assertElementNotContains('.page-content', 'javascript'); - } - } - - public function test_page_inline_on_attributes_show_if_configured() - { - $this->asEditor(); - $page = $this->entities->page(); - config()->set('app.allow_content_scripts', 'true'); - - $script = '

Hello

'; - $page->html = "escape {$script}"; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertSee($script, false); - $pageView->assertDontSee('

Hello

', false); - } - public function test_duplicate_ids_does_not_break_page_render() { $this->asEditor(); @@ -649,6 +304,7 @@ public function test_page_markdown_strikethrough_rendering() public function test_page_markdown_single_html_comment_saving() { + config()->set('app.content_filtering', 'jfh'); $this->asEditor(); $page = $this->entities->page(); @@ -656,7 +312,7 @@ public function test_page_markdown_single_html_comment_saving() $this->put($page->getUrl(), [ 'name' => $page->name, 'markdown' => $content, 'html' => '', 'summary' => '', - ]); + ])->assertRedirect(); $page->refresh(); $this->assertStringMatchesFormat($content, $page->html); diff --git a/tests/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php index fe98e32080b..3f4b7d193ce 100644 --- a/tests/SecurityHeaderTest.php +++ b/tests/SecurityHeaderTest.php @@ -93,14 +93,14 @@ public function test_script_csp_nonce_changes_per_request() $this->assertNotEquals($firstHeader, $secondHeader); } - public function test_allow_content_scripts_settings_controls_csp_script_headers() + public function test_content_filtering_config_controls_csp_script_headers() { - config()->set('app.allow_content_scripts', true); + config()->set('app.content_filtering', ''); $resp = $this->get('/'); $scriptHeader = $this->getCspHeader($resp, 'script-src'); $this->assertEmpty($scriptHeader); - config()->set('app.allow_content_scripts', false); + config()->set('app.content_filtering', 'j'); $resp = $this->get('/'); $scriptHeader = $this->getCspHeader($resp, 'script-src'); $this->assertNotEmpty($scriptHeader); From 8a221f64e4e1453966576a06b3bb82393ae735c3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Feb 2026 10:11:48 +0000 Subject: [PATCH 132/398] Content Filtering: Covered new config options and filters with tests --- app/Config/app.php | 8 +-- app/Entities/Tools/PageContent.php | 3 +- phpunit.xml | 1 + tests/Entity/PageContentFilteringTest.php | 75 +++++++++++++++++++++++ tests/Unit/ConfigTest.php | 14 +++++ 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/app/Config/app.php b/app/Config/app.php index acd27e98c02..7aa94b4f2f9 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -42,17 +42,17 @@ // Even when overridden the WYSIWYG editor may still escape script content. 'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false), - // Control the behaviour of page content filtering. + // Control the behaviour of content filtering, primarily used for page content. // This setting is a collection of characters which represent different available filters: - // - j - Filter out JavaScript based content - // - h - Filter out unexpected, potentially dangerous, HTML elements + // - j - Filter out JavaScript and unknown binary data based content + // - h - Filter out unexpected, and potentially dangerous, HTML elements // - f - Filter out unexpected form elements // - a - Run content through a more complex allow-list filter // This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used. // Note: These filters are a best attempt, and may not be 100% effective. They are typically a layer used in addition to other security measures. // TODO - Add to example env // TODO - Remove allow_content_scripts option above - 'content_filtering' => env('CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jfha'), + 'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'), // Allow server-side fetches to be performed to potentially unknown // and user-provided locations. Primarily used in exports when loading diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index f8a0617395b..4f72e7c490d 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -341,7 +341,8 @@ protected function getContentCacheKey(string $html): string $contentId = $this->page->id; $contentTime = $this->page->updated_at?->timestamp ?? time(); $appVersion = AppVersion::get(); - return "page-content-cache::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}"; + $filterConfig = config('app.content_filtering') ?? ''; + return "page-content-cache::{$filterConfig}::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}"; } /** diff --git a/phpunit.xml b/phpunit.xml index 8a7ab9cb7a3..94fc002b704 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -34,6 +34,7 @@ + diff --git a/tests/Entity/PageContentFilteringTest.php b/tests/Entity/PageContentFilteringTest.php index e1295034d68..8103fae1d2b 100644 --- a/tests/Entity/PageContentFilteringTest.php +++ b/tests/Entity/PageContentFilteringTest.php @@ -22,6 +22,8 @@ public function test_page_content_scripts_removed_by_default() public function test_more_complex_content_script_escaping_scenarios() { + config()->set('app.content_filtering', 'j'); + $checks = [ "

Some script

", "

Some script

", @@ -47,6 +49,8 @@ public function test_more_complex_content_script_escaping_scenarios() public function test_js_and_base64_src_urls_are_removed() { + config()->set('app.content_filtering', 'j'); + $checks = [ '', '', @@ -89,6 +93,8 @@ public function test_js_and_base64_src_urls_are_removed() public function test_javascript_uri_links_are_removed() { + config()->set('app.content_filtering', 'j'); + $checks = [ '
'; + $page->save(); + + $this->asEditor()->get($page->getUrl())->assertSee('dont-see-this', false); + + config()->set('app.content_filtering', 'f'); + $this->get($page->getUrl())->assertDontSee('dont-see-this', false); + } + public function test_form_actions_with_javascript_are_removed() { + config()->set('app.content_filtering', 'j'); + $checks = [ '', 'Click me', @@ -139,6 +160,8 @@ public function test_form_actions_with_javascript_are_removed() public function test_form_elements_are_removed() { + config()->set('app.content_filtering', 'f'); + $checks = [ '

thisisacattofind

thisdogshouldnotbefound
', '

thisisacattofind

', @@ -182,6 +205,8 @@ public function test_form_elements_are_removed() public function test_form_attributes_are_removed() { + config()->set('app.content_filtering', 'f'); + $withinSvgSample = <<<'TESTCASE' @@ -229,6 +254,8 @@ public function test_form_attributes_are_removed() public function test_metadata_redirects_are_removed() { + config()->set('app.content_filtering', 'h'); + $checks = [ '', '', @@ -253,6 +280,8 @@ public function test_metadata_redirects_are_removed() public function test_page_inline_on_attributes_removed_by_default() { + config()->set('app.content_filtering', 'j'); + $this->asEditor(); $page = $this->entities->page(); $script = '

Hello

'; @@ -267,6 +296,8 @@ public function test_page_inline_on_attributes_removed_by_default() public function test_more_complex_inline_on_attributes_escaping_scenarios() { + config()->set('app.content_filtering', 'j'); + $checks = [ '

Hello

', '

Hello

', @@ -308,6 +339,8 @@ public function test_page_content_scripts_show_with_filters_disabled() public function test_svg_script_usage_is_removed() { + config()->set('app.content_filtering', 'j'); + $checks = [ '', '', @@ -350,4 +383,46 @@ public function test_page_inline_on_attributes_show_with_filters_disabled() $pageView->assertSee($script, false); $pageView->assertDontSee('

Hello

', false); } + + public function test_non_content_filtering_is_controlled_by_config() + { + config()->set('app.content_filtering', 'h'); + $page = $this->entities->page(); + $html = <<<'HTML' + +

inbetweenpsection

+ + +superbeans! + +HTML; + + $page->html = $html; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertDontSee('superbeans', false); + $resp->assertSee('inbetweenpsection', false); + } + + public function test_non_content_filtering() + { + config()->set('app.content_filtering', 'h'); + } + + public function test_allow_list_filtering_is_controlled_by_config() + { + config()->set('app.content_filtering', ''); + $page = $this->entities->page(); + $page->html = '
Hello!
'; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertSee('style="position: absolute; left: 0;color:#00FFEE;"', false); + + config()->set('app.content_filtering', 'a'); + $resp = $this->get($page->getUrl()); + $resp->assertDontSee('style="position: absolute; left: 0;color:#00FFEE;"', false); + $resp->assertSee('style="color:#00FFEE;"', false); + } } diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 7795a861a0c..63dd04fb1d2 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -170,6 +170,20 @@ public function test_mysql_host_parsed_as_expected() } } + public function test_content_filtering_defaults_to_enabled() + { + $this->runWithEnv(['APP_CONTENT_FILTERING' => null, 'ALLOW_CONTENT_SCRIPTS' => null], function () { + $this->assertEquals('jhfa', config('app.content_filtering')); + }); + } + + public function test_allow_content_scripts_disables_content_filtering() + { + $this->runWithEnv(['APP_CONTENT_FILTERING' => null, 'ALLOW_CONTENT_SCRIPTS' => 'true'], function () { + $this->assertEquals('', config('app.content_filtering')); + }); + } + /** * Set an environment variable of the given name and value * then check the given config key to see if it matches the given result. From 50e8501027115a70983c6fcecc7c411474221cf1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Feb 2026 13:02:24 +0000 Subject: [PATCH 133/398] Content Filter: Added extra object filtering Was blocked by CSP anyway, but best to have an extra layer. --- app/Util/HtmlContentFilter.php | 8 +++-- tests/Entity/PageContentFilteringTest.php | 37 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/Util/HtmlContentFilter.php b/app/Util/HtmlContentFilter.php index 79b1cdc93c4..45144a0e823 100644 --- a/app/Util/HtmlContentFilter.php +++ b/app/Util/HtmlContentFilter.php @@ -61,13 +61,17 @@ protected function filterOutScriptsFromDocument(HtmlDocument $doc): void $badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']'); static::removeNodes($badForms); - // Remove data or JavaScript iFrames + // Remove data or JavaScript iFrames & embeds $badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]'); static::removeNodes($badIframes); + // Remove data or JavaScript objects + $badObjects = $doc->queryXPath('//*[' . static::xpathContains('@data', 'data:') . '] | //*[' . static::xpathContains('@data', 'javascript:') . ']'); + static::removeNodes($badObjects); + // Remove attributes, within svg children, hiding JavaScript or data uris. // A bunch of svg element and attribute combinations expose xss possibilities. - // For example, SVG animate tag can exploit javascript in values. + // For example, SVG animate tag can exploit JavaScript in values. $badValuesAttrs = $doc->queryXPath('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']'); static::removeAttributes($badValuesAttrs); diff --git a/tests/Entity/PageContentFilteringTest.php b/tests/Entity/PageContentFilteringTest.php index 8103fae1d2b..98297a093b8 100644 --- a/tests/Entity/PageContentFilteringTest.php +++ b/tests/Entity/PageContentFilteringTest.php @@ -69,6 +69,20 @@ public function test_js_and_base64_src_urls_are_removed() '', '', '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', ]; $this->asEditor(); @@ -81,6 +95,8 @@ public function test_js_and_base64_src_urls_are_removed() $pageView = $this->get($page->getUrl()); $pageView->assertStatus(200); $html = $this->withHtml($pageView); + $html->assertElementNotContains('.page-content', 'assertElementNotContains('.page-content', 'data='); $html->assertElementNotContains('.page-content', ''); @@ -425,4 +441,25 @@ public function test_allow_list_filtering_is_controlled_by_config() $resp->assertDontSee('style="position: absolute; left: 0;color:#00FFEE;"', false); $resp->assertSee('style="color:#00FFEE;"', false); } + + public function test_allow_list_style_filtering() + { + $testCasesExpectedByInput = [ + '
Hello!
' => '
Hello!
', + '
Hello!
' => '
Hello!
', + '
Hello!
' => '
Hello!
', + ]; + + config()->set('app.content_filtering', 'a'); + $page = $this->entities->page(); + $this->asEditor(); + + foreach ($testCasesExpectedByInput as $input => $expected) { + $page->html = $input; + $page->save(); + $resp = $this->get($page->getUrl()); + + $resp->assertSee($expected, false); + } + } } From 3fa1174e7a879fe8d128e89709889628c496838d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Feb 2026 13:46:45 +0000 Subject: [PATCH 134/398] Content filtering: Updated config and readme attribution --- .env.example.complete | 17 ++++++++++++++++- app/Config/app.php | 13 +++---------- readme.md | 5 +++-- tests/Unit/ConfigTest.php | 7 +++++++ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/.env.example.complete b/.env.example.complete index 18e7bd00d9c..ebebaf9e3e8 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -351,10 +351,25 @@ EXPORT_PDF_COMMAND_TIMEOUT=15 # Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections. WKHTMLTOPDF=false -# Allow

'; + $page->save(); $this->getJson('/ajax/page/' . $page->id)->assertJson([ - 'html' => $page->html, + 'html' => '

test content

', ]); } From a2017ffa559d96669f207863ca202d2e50363622 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Feb 2026 18:22:13 +0000 Subject: [PATCH 139/398] Caching: Altered purifier cache folder to be server-created Moved from a static folder to a dynamically created folder in the framework/cache directory, to increase the chance that it's created with server-writable permissions. This is due to an issue where users had permission issues, since adding a new folder means it's created by the git user and often non-web-writable. --- app/Util/ConfiguredHtmlPurifier.php | 18 +++++++++++++++--- storage/framework/purifier/.gitignore | 2 -- 2 files changed, 15 insertions(+), 5 deletions(-) delete mode 100644 storage/framework/purifier/.gitignore diff --git a/app/Util/ConfiguredHtmlPurifier.php b/app/Util/ConfiguredHtmlPurifier.php index 014b2a3bf2b..87580da8bf1 100644 --- a/app/Util/ConfiguredHtmlPurifier.php +++ b/app/Util/ConfiguredHtmlPurifier.php @@ -22,8 +22,13 @@ class ConfiguredHtmlPurifier public function __construct() { + // This is done by the web-server at run-time, with the existing + // storage/framework/cache folder to ensure we're using a server-writable folder. + $cachePath = storage_path('framework/cache/purifier'); + $this->createCacheFolderIfNeeded($cachePath); + $config = HTMLPurifier_HTML5Config::createDefault(); - $this->setConfig($config); + $this->setConfig($config, $cachePath); $this->resetCacheIfNeeded($config); $htmlDef = $config->getDefinition('HTML', true, true); @@ -34,6 +39,13 @@ public function __construct() $this->purifier = new HTMLPurifier($config); } + protected function createCacheFolderIfNeeded(string $cachePath): void + { + if (!file_exists($cachePath)) { + mkdir($cachePath, 0777, true); + } + } + protected function resetCacheIfNeeded(HTMLPurifier_Config $config): void { if (self::$cachedChecked) { @@ -53,9 +65,9 @@ protected function resetCacheIfNeeded(HTMLPurifier_Config $config): void self::$cachedChecked = true; } - protected function setConfig(HTMLPurifier_Config $config): void + protected function setConfig(HTMLPurifier_Config $config, string $cachePath): void { - $config->set('Cache.SerializerPath', storage_path('framework/purifier')); + $config->set('Cache.SerializerPath', $cachePath); $config->set('Core.AllowHostnameUnderscore', true); $config->set('CSS.AllowTricky', true); $config->set('HTML.SafeIframe', true); diff --git a/storage/framework/purifier/.gitignore b/storage/framework/purifier/.gitignore deleted file mode 100644 index c96a04f008e..00000000000 --- a/storage/framework/purifier/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file From e1de1f0583352a71161a1eea5ca3b54c847dc95f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Feb 2026 18:34:14 +0000 Subject: [PATCH 140/398] git: Added old purifier location to gitignore --- storage/framework/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore index 05c4471f2b5..8d89041780c 100755 --- a/storage/framework/.gitignore +++ b/storage/framework/.gitignore @@ -7,3 +7,4 @@ routes.php routes.scanned.php schedule-* services.json +purifier/ From 9d15c79feec450be21b44f6828d62f6feef01b6a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 18 Feb 2026 19:24:06 +0000 Subject: [PATCH 141/398] Deps: Updated PHP package versions --- composer.lock | 54 +++++++++++++++++++++++++-------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/composer.lock b/composer.lock index 08a932a38a1..c72b6dd1bdb 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.35", + "version": "3.369.36", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "0f3e296342fe965271b5dd0bded4a18bdab8aba5" + "reference": "2a69e7df5e03be9e08f9f73fb6a8cc9dd63b59c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0f3e296342fe965271b5dd0bded4a18bdab8aba5", - "reference": "0f3e296342fe965271b5dd0bded4a18bdab8aba5", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2a69e7df5e03be9e08f9f73fb6a8cc9dd63b59c0", + "reference": "2a69e7df5e03be9e08f9f73fb6a8cc9dd63b59c0", "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.369.35" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.36" }, - "time": "2026-02-16T19:15:41+00:00" + "time": "2026-02-17T19:45:01+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1800,16 +1800,16 @@ }, { "name": "laravel/framework", - "version": "v12.51.0", + "version": "v12.52.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "ce4de3feb211e47c4f959d309ccf8a2733b1bc16" + "reference": "d5511fa74f4608dbb99864198b1954042aa8d5a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/ce4de3feb211e47c4f959d309ccf8a2733b1bc16", - "reference": "ce4de3feb211e47c4f959d309ccf8a2733b1bc16", + "url": "https://api.github.com/repos/laravel/framework/zipball/d5511fa74f4608dbb99864198b1954042aa8d5a7", + "reference": "d5511fa74f4608dbb99864198b1954042aa8d5a7", "shasum": "" }, "require": { @@ -2018,7 +2018,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-10T18:20:19+00:00" + "time": "2026-02-17T17:07:04+00:00" }, { "name": "laravel/prompts", @@ -8946,36 +8946,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.0", + "version": "v8.9.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "f52cab234f37641bd759c0ad56de17f632851419" + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f52cab234f37641bd759c0ad56de17f632851419", - "reference": "f52cab234f37641bd759c0ad56de17f632851419", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", - "nunomaduro/termwind": "^2.3.3", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", "symfony/console": "^7.4.4 || ^8.0.4" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", - "phpunit/phpunit": "<11.5.50 || >=13.0.0" + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { "brianium/paratest": "^7.8.5", "larastan/larastan": "^3.9.2", - "laravel/framework": "^11.48.0 || ^12.51.0", + "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.3.2", - "sebastian/environment": "^7.2.1 || ^8.0.3" + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" }, "type": "library", "extra": { @@ -9038,7 +9038,7 @@ "type": "patreon" } ], - "time": "2026-02-16T23:05:52+00:00" + "time": "2026-02-17T17:33:08+00:00" }, { "name": "phar-io/manifest", @@ -9560,16 +9560,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.53", + "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607" + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607", - "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { @@ -9642,7 +9642,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.53" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { @@ -9666,7 +9666,7 @@ "type": "tidelift" } ], - "time": "2026-02-10T12:28:25+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "sebastian/cli-parser", From a8d96fd3892c5f98136e93c7d50fa689f0869b3d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 18 Feb 2026 19:33:35 +0000 Subject: [PATCH 142/398] Content filter: Allowed custom diagram attribute in allow-list For #6026 --- app/Util/ConfiguredHtmlPurifier.php | 7 +++++++ tests/Entity/PageContentFilteringTest.php | 1 + 2 files changed, 8 insertions(+) diff --git a/app/Util/ConfiguredHtmlPurifier.php b/app/Util/ConfiguredHtmlPurifier.php index 87580da8bf1..ab23333882a 100644 --- a/app/Util/ConfiguredHtmlPurifier.php +++ b/app/Util/ConfiguredHtmlPurifier.php @@ -134,6 +134,13 @@ public function configureDefinition(HTMLPurifier_HTMLDefinition $definition): vo 'value' => 'Text', ] ); + + // Allow the drawio-diagram attribute on div elements + $definition->addAttribute( + 'div', + 'drawio-diagram', + 'Number', + ); } public function purify(string $html): string diff --git a/tests/Entity/PageContentFilteringTest.php b/tests/Entity/PageContentFilteringTest.php index c048c09dc5f..4f77e063369 100644 --- a/tests/Entity/PageContentFilteringTest.php +++ b/tests/Entity/PageContentFilteringTest.php @@ -463,6 +463,7 @@ public function test_allow_list_style_filtering() '
Hello!
' => '
Hello!
', '
Hello!
' => '
Hello!
', '
Hello!
' => '
Hello!
', + '
Hello!
' => '
Hello!
', ]; config()->set('app.content_filtering', 'a'); From 80204518a25bb98beb0790f06496f19ec66d61ca Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 19 Feb 2026 23:25:00 +0000 Subject: [PATCH 143/398] Page Content: Better handling for empty content filtering For #6028 --- app/Util/HtmlDocument.php | 8 +++++++- tests/Entity/PageEditorTest.php | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/Util/HtmlDocument.php b/app/Util/HtmlDocument.php index 7517955b9f6..c1ec2cf415b 100644 --- a/app/Util/HtmlDocument.php +++ b/app/Util/HtmlDocument.php @@ -103,7 +103,13 @@ public function getElementById(string $elementId): ?DOMElement */ public function getBody(): DOMNode { - return $this->document->getElementsByTagName('body')[0]; + $bodies = $this->document->getElementsByTagName('body'); + + if ($bodies->length === 0) { + return new DOMElement('body', ''); + } + + return $bodies[0]; } /** diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php index 4cd3c1671c5..67283f70420 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -282,4 +282,23 @@ public function test_editor_html_content_is_filtered_if_loaded_by_a_different_us $resp->assertOk(); $resp->assertDontSee('hellotherethisisaturtlemonster', false); } + + public function test_editor_html_filtered_does_not_cause_error_if_empty() + { + $emptyExamples = ['', '

', '

 

', ' ', "\n"]; + $editor = $this->users->editor(); + $page = $this->entities->page(); + $page->updated_by = $editor->id; + + foreach ($emptyExamples as $emptyExample) { + $page->html = $emptyExample; + $page->save(); + + $resp = $this->asAdmin()->get($page->getUrl('edit')); + $resp->assertOk(); + + $resp = $this->asAdmin()->get("/ajax/page/{$page->id}"); + $resp->assertOk(); + } + } } From 8e99fc678352a9757348cfa1a74982b3c7f6cfa6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 20 Feb 2026 11:23:26 +0000 Subject: [PATCH 144/398] Books: On delete, redirect to shelf if in context For #6029 Added tests to cover --- app/Entities/Controllers/BookController.php | 5 +++++ tests/Entity/BookTest.php | 14 ++++++++++++++ tests/Helpers/EntityProvider.php | 8 ++++++++ 3 files changed, 27 insertions(+) diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index c94057fa99b..fca530f8adf 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -224,9 +224,14 @@ public function destroy(string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission(Permission::BookDelete, $book); + $contextShelf = $this->shelfContext->getContextualShelfForBook($book); $this->bookRepo->destroy($book); + if ($contextShelf) { + return redirect($contextShelf->getUrl()); + } + return redirect('/books'); } diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index a7142f03736..5f0aabc38ca 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -154,6 +154,20 @@ public function test_delete() $this->assertNotificationContains($redirectReq, 'Book Successfully Deleted'); } + public function test_delete_with_shelf_context_returns_to_shelf_view_after_delete() + { + $shelf = $this->entities->shelfHasBooks(); + /** @var Book $book */ + $book = $shelf->books()->first(); + + $this->asEditor()->get($shelf->getUrl()); + $this->get($book->getUrl()); + $this->get($book->getUrl('/delete')); + $resp = $this->delete($book->getUrl()); + + $resp->assertRedirect($shelf->getUrl()); + } + public function test_cancel_on_create_page_leads_back_to_books_listing() { $resp = $this->asEditor()->get('/create-book'); diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php index 5163cef14a0..d4cdce51285 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -110,6 +110,14 @@ public function shelf(callable|null $queryFilter = null): Bookshelf return $shelf; } + /** + * Get a shelf that has books assigned. + */ + public function shelfHasBooks(): Bookshelf + { + return $this->shelf(fn(Builder $query) => $query->whereHas('books')); + } + /** * Get all entity types from the system. * @return array{page: Page, chapter: Chapter, book: Book, bookshelf: Bookshelf} From 229a99ba24d7676f5608bcb662bbe047d4a3bf42 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 20 Feb 2026 14:22:54 +0000 Subject: [PATCH 145/398] Descriptions: Improved empty field handling, reduces whitespace For #5724 --- app/Entities/Tools/EntityHtmlDescription.php | 5 +++++ .../form/description-html-input.blade.php | 2 +- tests/Entity/BookTest.php | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/Entities/Tools/EntityHtmlDescription.php b/app/Entities/Tools/EntityHtmlDescription.php index b14deb257a7..795aaf4aac1 100644 --- a/app/Entities/Tools/EntityHtmlDescription.php +++ b/app/Entities/Tools/EntityHtmlDescription.php @@ -50,6 +50,11 @@ public function getHtml(bool $raw = false): string return $html; } + $isEmpty = empty(trim(strip_tags($html))); + if ($isEmpty) { + return '

'; + } + return HtmlContentFilter::removeActiveContentFromHtmlString($html); } diff --git a/resources/views/form/description-html-input.blade.php b/resources/views/form/description-html-input.blade.php index 983d2fb8371..4b0a74df1b3 100644 --- a/resources/views/form/description-html-input.blade.php +++ b/resources/views/form/description-html-input.blade.php @@ -1,7 +1,7 @@ + @if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->descriptionInfo()->getHtml() }}@else{{ '

' }}@endif @if($errors->has('description_html'))
{{ $errors->first('description_html') }}
@endif \ No newline at end of file diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 5f0aabc38ca..6082c59de61 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -278,4 +278,25 @@ public function test_show_view_displays_description_if_no_description_html_set() $resp = $this->asEditor()->get($book->getUrl()); $resp->assertSee("

My great
\ndescription
\n
\nwith newlines

", false); } + + public function test_description_with_only_br_tags_results_in_empty_p_tag_used_on_show() + { + $descriptions = [ + '


', + '





', + '









', + ]; + $book = $this->entities->book(); + $this->asEditor(); + + foreach ($descriptions as $descriptionTestCase) { + $book->description_html = $descriptionTestCase; + $book->save(); + + $resp = $this->get($book->getUrl()); + $html = $this->withHtml($resp); + $descriptionHtml = $html->getInnerHtml('.book-content > div.text-muted:first-child'); + $this->assertEquals('

', $descriptionHtml); + } + } } From 23f3f35f6b23a5b491b181c1d1628304a17ef6da Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Feb 2026 13:56:50 +0000 Subject: [PATCH 146/398] Readme: Updated sponsors --- readme.md | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/readme.md b/readme.md index d134a24799a..e655c538f3c 100644 --- a/readme.md +++ b/readme.md @@ -48,17 +48,13 @@ Big thanks to these companies for supporting the project. #### Gold Sponsor - -
- Federated.computer -
Diagrams.net
- onyx.app + onyx.app
@@ -81,26 +77,23 @@ Big thanks to these companies for supporting the project. - - Schroeck IT Consulting - Practinet - - Route4Me - Route Optimizer and Route Planner Software + + phamos - - SiteSpeakAI - + + + Admin Intelligence From 6808292c90ce9cced8562651b12635f5ac6a3f3b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Feb 2026 15:56:12 +0000 Subject: [PATCH 147/398] Editors: Made drawings appear clickiable via cursor During review of #5864 --- resources/sass/_editor.scss | 3 +++ resources/sass/_forms.scss | 3 +++ resources/sass/_tinymce.scss | 3 +-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index cd4bb5d2ef0..0580d7377c9 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -451,6 +451,9 @@ body.editor-is-fullscreen { outline: 1px dashed var(--editor-color-primary); outline-offset: 1px; } +.editor-content-area [drawio-diagram] { + cursor: pointer; +} .editor-table-marker { position: fixed; diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 13a4232fc7e..f1a2feabf5c 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -142,6 +142,9 @@ padding-inline-end: 12px; max-width: 864px; } + [drawio-diagram] { + cursor: pointer; + } [drawio-diagram]:hover { outline: 2px solid var(--color-primary); } diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index 561bb23cae0..d662550c613 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -205,9 +205,8 @@ body.page-content.mce-content-body { } /** - * Set correct cursor for drawio + * Ensure cursor indicates that drawings are clickable */ - .page-content.mce-content-body [drawio-diagram] { cursor: pointer; } From 7aef0a48b309d3e74193a1e5a7a236147f307f32 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Feb 2026 08:07:41 +0000 Subject: [PATCH 148/398] Content: Updated filters to allow some required attributes - Allows target attribute on links. - Allows custom mention attribute on links. Adds test case to cover these. For #6034 --- app/Util/ConfiguredHtmlPurifier.php | 8 ++++++++ tests/Entity/PageContentFilteringTest.php | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/app/Util/ConfiguredHtmlPurifier.php b/app/Util/ConfiguredHtmlPurifier.php index ab23333882a..1f2528e7155 100644 --- a/app/Util/ConfiguredHtmlPurifier.php +++ b/app/Util/ConfiguredHtmlPurifier.php @@ -71,6 +71,8 @@ protected function setConfig(HTMLPurifier_Config $config, string $cachePath): vo $config->set('Core.AllowHostnameUnderscore', true); $config->set('CSS.AllowTricky', true); $config->set('HTML.SafeIframe', true); + $config->set('HTML.TargetNoopener', false); + $config->set('HTML.TargetNoreferrer', false); $config->set('Attr.EnableID', true); $config->set('Attr.ID.HTML5', true); $config->set('Output.FixInnerHTML', false); @@ -141,6 +143,12 @@ public function configureDefinition(HTMLPurifier_HTMLDefinition $definition): vo 'drawio-diagram', 'Number', ); + + // Allow target="_blank" on links + $definition->addAttribute('a', 'target', 'Enum#_blank'); + + // Allow mention-ids on links + $definition->addAttribute('a', 'data-mention-user-id', 'Number'); } public function purify(string $html): string diff --git a/tests/Entity/PageContentFilteringTest.php b/tests/Entity/PageContentFilteringTest.php index 4f77e063369..449189a898c 100644 --- a/tests/Entity/PageContentFilteringTest.php +++ b/tests/Entity/PageContentFilteringTest.php @@ -478,4 +478,25 @@ public function test_allow_list_style_filtering() $resp->assertSee($expected, false); } } + + public function test_allow_list_does_not_filter_cases() + { + $testCasesExpectedByInput = [ + '

New tab linkydoodle

', + '

@mentionusertext

', + '
Hello

Mydetailshere

', + ]; + + config()->set('app.content_filtering', 'a'); + $page = $this->entities->page(); + $this->asEditor(); + + foreach ($testCasesExpectedByInput as $input) { + $page->html = $input; + $page->save(); + $resp = $this->get($page->getUrl()); + + $resp->assertSee($input, false); + } + } } From 9a12e3a8b78f0334fc7e376c9d805037edbd3aa3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 24 Feb 2026 10:25:17 +0000 Subject: [PATCH 149/398] Book API: Added shelves list to show endpoint For #6006 Added test to cover. --- .../Controllers/BookApiController.php | 15 +++++++-- app/Entities/Models/Bookshelf.php | 2 +- tests/Api/BooksApiTest.php | 31 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index 325f0583c67..c47ece22569 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -7,11 +7,14 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Queries\BookQueries; +use BookStack\Entities\Queries\BookshelfQueries; use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Tools\BookContents; use BookStack\Http\ApiController; use BookStack\Permissions\Permission; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -21,6 +24,7 @@ public function __construct( protected BookRepo $bookRepo, protected BookQueries $queries, protected PageQueries $pageQueries, + protected BookshelfQueries $shelfQueries, ) { } @@ -60,13 +64,20 @@ public function create(Request $request) * View the details of a single book. * The response data will contain a 'content' property listing the chapter and pages directly within, in * the same structure as you'd see within the BookStack interface when viewing a book. Top-level - * contents will have a 'type' property to distinguish between pages & chapters. + * contents will have a 'type' property to distinguish between pages and chapters. */ public function read(string $id) { $book = $this->queries->findVisibleByIdOrFail(intval($id)); $book = $this->forJsonDisplay($book); - $book->load(['createdBy', 'updatedBy', 'ownedBy']); + $book->load([ + 'createdBy', + 'updatedBy', + 'ownedBy', + 'shelves' => function (BelongsToMany $query) { + $query->select(['id', 'name', 'slug'])->scopes('visible'); + } + ]); $contents = (new BookContents($book))->getTree(true, false)->all(); $contentsApiData = (new ApiEntityListFormatter($contents)) diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index 42dcc8f8f2c..320346512e1 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -19,7 +19,7 @@ class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInter public float $searchFactor = 1.2; - protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id']; + protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id']; protected $fillable = ['name']; /** diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 86e10f58acb..74f558f381b 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -188,6 +188,37 @@ public function test_read_endpoint_contents_nested_pages_has_permissions_applied $resp->assertJsonMissing(['name' => $customName]); } + public function test_read_endpoint_lists_visible_shelves_the_book_is_assigned_to() + { + $this->actingAsApiEditor(); + $shelf = $this->entities->shelf(); + $otherShelf = $this->entities->shelf(); + $book = $this->entities->book(); + $book->shelves()->detach(); + + $book->shelves()->attach($shelf); + $book->shelves()->attach($otherShelf); + + $this->assertEquals(2, $book->shelves()->count()); + + $this->permissions->disableEntityInheritedPermissions($otherShelf); + + $resp = $this->getJson("{$this->baseEndpoint}/{$book->id}"); + $resp->assertOk(); + $resp->assertJsonCount(1, 'shelves'); + $resp->assertJson([ + 'shelves' => [ + [ + 'id' => $shelf->id, + 'name' => $shelf->name, + 'slug' => $shelf->slug, + ] + ] + ]); + $resp->assertJsonMissingPath('shelves.0.description'); + $resp->assertJsonMissingPath('shelves.0.pivot'); + } + public function test_update_endpoint() { $this->actingAsApiEditor(); From dd42b9b43f14ed7a8ee06b72fe8be32394c81564 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Feb 2026 08:54:12 +0000 Subject: [PATCH 150/398] Text: Updated per-page display limits description To be more sensible & direct as per MtheBird's suggestion. Closes #6005 --- lang/en/settings.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lang/en/settings.php b/lang/en/settings.php index c68605fe1f8..c4d1eb136eb 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Maintenance', From 10c46534e0d582480316d851356710e18b2143c9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Feb 2026 09:34:33 +0000 Subject: [PATCH 151/398] Logical Theme: Added OIDC_AUTH_PRE_REDIRECT event Allows customization of the auth URL before the user is redirected to that URL. Related to #6014 --- app/Access/Oidc/OidcService.php | 5 +++++ app/Theming/ThemeEvents.php | 11 +++++++++++ tests/Auth/OidcTest.php | 28 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php index d6f6ef156e4..a84bd320513 100644 --- a/app/Access/Oidc/OidcService.php +++ b/app/Access/Oidc/OidcService.php @@ -49,6 +49,11 @@ public function login(): array $url = $provider->getAuthorizationUrl(); session()->put('oidc_pkce_code', $provider->getPkceCode() ?? ''); + $returnUrl = Theme::dispatch(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $url); + if (is_string($returnUrl)) { + $url = $returnUrl; + } + return [ 'url' => $url, 'state' => $provider->getState(), diff --git a/app/Theming/ThemeEvents.php b/app/Theming/ThemeEvents.php index c6266b32b9c..71778ec4484 100644 --- a/app/Theming/ThemeEvents.php +++ b/app/Theming/ThemeEvents.php @@ -87,6 +87,17 @@ class ThemeEvents */ const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure'; + /** + * OIDC auth pre-redirect event. + * Runs just before BookStack redirects the user to the identity provider for authentication. + * Provides the redirect URL that will be used. + * If the listener returns a string value, that will be used as the redirect URL instead. + * + * @param string $redirectUrl + * @return string|null + */ + const OIDC_AUTH_PRE_REDIRECT = 'oidc_auth_pre_redirect'; + /** * OIDC ID token pre-validate event. * Runs just before BookStack validates the user ID token data upon login. diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index 710e5375785..8508568f1f4 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -822,6 +822,34 @@ public function test_oidc_id_token_pre_validate_theme_event_with_return() ]); } + public function test_oidc_auth_pre_redirect_theme_event_with_return() + { + $args = []; + $callback = function (...$eventArgs) use (&$args) { + $args = $eventArgs; + return 'https://cats.example.com?beans=true'; + }; + Theme::listen(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $callback); + + $resp = $this->post('/oidc/login'); + $resp->assertRedirect('https://cats.example.com?beans=true'); + + $this->assertCount(1, $args); + $this->assertStringStartsWith('https://oidc.local/auth', $args[0]); + } + + public function test_oidc_auth_pre_redirect_theme_event_with_no_return() + { + $callback = function ($redirectUrl) { + $redirectUrl = 'cat'; + }; + Theme::listen(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $callback); + + $resp = $this->post('/oidc/login'); + $redirect = $resp->headers->get('Location'); + $this->assertStringStartsWith('https://oidc.local/auth?', $redirect); + } + public function test_pkce_used_on_authorize_and_access() { // Start auth From 25ed242f61293731210e7962ac63561796396654 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Feb 2026 10:09:41 +0000 Subject: [PATCH 152/398] Deps: Updated PHP package versions --- composer.lock | 330 ++++++++++++++++++++++++++------------------------ 1 file changed, 169 insertions(+), 161 deletions(-) diff --git a/composer.lock b/composer.lock index c72b6dd1bdb..18d0da62191 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.36", + "version": "3.371.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "2a69e7df5e03be9e08f9f73fb6a8cc9dd63b59c0" + "reference": "32090a8ac3ec8859cb83bdde800b8f0ecf92d8ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2a69e7df5e03be9e08f9f73fb6a8cc9dd63b59c0", - "reference": "2a69e7df5e03be9e08f9f73fb6a8cc9dd63b59c0", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/32090a8ac3ec8859cb83bdde800b8f0ecf92d8ec", + "reference": "32090a8ac3ec8859cb83bdde800b8f0ecf92d8ec", "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.369.36" + "source": "https://github.com/aws/aws-sdk-php/tree/3.371.2" }, - "time": "2026-02-17T19:45:01+00:00" + "time": "2026-02-26T19:06:10+00:00" }, { "name": "bacon/bacon-qr-code", @@ -982,16 +982,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.2", + "version": "v7.0.3", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", "shasum": "" }, "require": { @@ -1039,9 +1039,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" }, - "time": "2025-12-16T22:17:28+00:00" + "time": "2026-02-25T22:16:40+00:00" }, { "name": "fruitcake/php-cors", @@ -1657,16 +1657,16 @@ }, { "name": "intervention/image", - "version": "3.11.6", + "version": "3.11.7", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1" + "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/5f6d27d9fd56312c47f347929e7ac15345c605a1", - "reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1", + "url": "https://api.github.com/repos/Intervention/image/zipball/2159bcccff18f09d2a392679b81a82c5a003f9bb", + "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb", "shasum": "" }, "require": { @@ -1713,7 +1713,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.6" + "source": "https://github.com/Intervention/image/tree/3.11.7" }, "funding": [ { @@ -1729,7 +1729,7 @@ "type": "ko_fi" } ], - "time": "2025-12-17T13:38:29+00:00" + "time": "2026-02-19T13:11:17+00:00" }, { "name": "knplabs/knp-snappy", @@ -1800,16 +1800,16 @@ }, { "name": "laravel/framework", - "version": "v12.52.0", + "version": "v12.53.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "d5511fa74f4608dbb99864198b1954042aa8d5a7" + "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/d5511fa74f4608dbb99864198b1954042aa8d5a7", - "reference": "d5511fa74f4608dbb99864198b1954042aa8d5a7", + "url": "https://api.github.com/repos/laravel/framework/zipball/f57f035c0d34503d9ff30be76159bb35a003cd1f", + "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f", "shasum": "" }, "require": { @@ -2018,7 +2018,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-17T17:07:04+00:00" + "time": "2026-02-24T14:35:15+00:00" }, { "name": "laravel/prompts", @@ -2081,16 +2081,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.9", + "version": "v2.0.10", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", - "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", "shasum": "" }, "require": { @@ -2138,36 +2138,36 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-03T06:55:34+00:00" + "time": "2026-02-20T19:59:49+00:00" }, { "name": "laravel/socialite", - "version": "v5.24.2", + "version": "v5.24.3", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613" + "reference": "0feb62267e7b8abc68593ca37639ad302728c129" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", + "url": "https://api.github.com/repos/laravel/socialite/zipball/0feb62267e7b8abc68593ca37639ad302728c129", + "reference": "0feb62267e7b8abc68593ca37639ad302728c129", "shasum": "" }, "require": { "ext-json": "*", "firebase/php-jwt": "^6.4|^7.0", "guzzlehttp/guzzle": "^6.0|^7.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "league/oauth1-client": "^1.11", "php": "^7.2|^8.0", "phpseclib/phpseclib": "^3.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.12.23", "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" }, @@ -2210,7 +2210,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-01-10T16:07:28+00:00" + "time": "2026-02-21T13:32:50+00:00" }, { "name": "laravel/tinker", @@ -2469,16 +2469,16 @@ }, { "name": "league/flysystem", - "version": "3.31.0", + "version": "3.32.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", "shasum": "" }, "require": { @@ -2546,22 +2546,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" }, - "time": "2026-01-23T15:38:47+00:00" + "time": "2026-02-25T17:01:41+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.31.0", + "version": "3.32.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "e36a2bc60b06332c92e4435047797ded352b446f" + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/e36a2bc60b06332c92e4435047797ded352b446f", - "reference": "e36a2bc60b06332c92e4435047797ded352b446f", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", "shasum": "" }, "require": { @@ -2601,9 +2601,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-02-25T16:46:44+00:00" }, { "name": "league/flysystem-local", @@ -3465,16 +3465,16 @@ }, { "name": "nette/schema", - "version": "v1.3.4", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7" + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7", - "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "shasum": "" }, "require": { @@ -3482,8 +3482,10 @@ "php": "8.1 - 8.5" }, "require-dev": { + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.6", - "phpstan/phpstan": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -3524,9 +3526,9 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.4" + "source": "https://github.com/nette/schema/tree/v1.3.5" }, - "time": "2026-02-08T02:54:00+00:00" + "time": "2026-02-23T03:47:12+00:00" }, { "name": "nette/utils", @@ -4186,16 +4188,16 @@ }, { "name": "predis/predis", - "version": "v3.4.0", + "version": "v3.4.1", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "1183f5732e6b10efd33f64984a96726eaecb59aa" + "reference": "0850f2f36ee179f0ff96c92c750e1366c6cd754c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/1183f5732e6b10efd33f64984a96726eaecb59aa", - "reference": "1183f5732e6b10efd33f64984a96726eaecb59aa", + "url": "https://api.github.com/repos/predis/predis/zipball/0850f2f36ee179f0ff96c92c750e1366c6cd754c", + "reference": "0850f2f36ee179f0ff96c92c750e1366c6cd754c", "shasum": "" }, "require": { @@ -4237,7 +4239,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v3.4.0" + "source": "https://github.com/predis/predis/tree/v3.4.1" }, "funding": [ { @@ -4245,7 +4247,7 @@ "type": "github" } ], - "time": "2026-02-11T17:30:28+00:00" + "time": "2026-02-23T19:51:21+00:00" }, { "name": "psr/clock", @@ -4980,33 +4982,35 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v9.1.0", + "version": "v9.2.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb" + "reference": "59373045e11ad47b5c18fc615feee0219e42f6d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", - "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/59373045e11ad47b5c18fc615feee0219e42f6d3", + "reference": "59373045e11ad47b5c18fc615feee0219e42f6d3", "shasum": "" }, "require": { "ext-iconv": "*", "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3" + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "1.4.0", "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.28 || 2.1.25", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.7", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6", - "phpunit/phpunit": "8.5.46", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.1.7", - "rector/type-perfect": "1.0.0 || 2.1.0" + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -5014,10 +5018,14 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "9.3.x-dev" } }, "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], "psr-4": { "Sabberworm\\CSS\\": "src/" } @@ -5048,9 +5056,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.1.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.2.0" }, - "time": "2025-09-14T07:37:21+00:00" + "time": "2026-02-21T17:12:03+00:00" }, { "name": "socialiteproviders/discord", @@ -5500,16 +5508,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "6d643a93b47398599124022eb24d97c153c12f27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27", + "reference": "6d643a93b47398599124022eb24d97c153c12f27", "shasum": "" }, "require": { @@ -5574,7 +5582,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.6" }, "funding": [ { @@ -5594,20 +5602,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-02-25T17:02:47+00:00" }, { "name": "symfony/css-selector", - "version": "v7.4.0", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" + "reference": "2e7c52c647b406e2107dd867db424a4dbac91864" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", - "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/2e7c52c647b406e2107dd867db424a4dbac91864", + "reference": "2e7c52c647b406e2107dd867db424a4dbac91864", "shasum": "" }, "require": { @@ -5643,7 +5651,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.0" + "source": "https://github.com/symfony/css-selector/tree/v7.4.6" }, "funding": [ { @@ -5663,7 +5671,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T13:39:42+00:00" + "time": "2026-02-17T07:53:42+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5977,16 +5985,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.0", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d551b38811096d0be9c4691d406991b47c0c630a" + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", - "reference": "d551b38811096d0be9c4691d406991b47c0c630a", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", "shasum": "" }, "require": { @@ -6023,7 +6031,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.0" + "source": "https://github.com/symfony/filesystem/tree/v7.4.6" }, "funding": [ { @@ -6043,20 +6051,20 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", "shasum": "" }, "require": { @@ -6091,7 +6099,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.5" + "source": "https://github.com/symfony/finder/tree/v7.4.6" }, "funding": [ { @@ -6111,20 +6119,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-29T09:40:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" + "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065", + "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065", "shasum": "" }, "require": { @@ -6173,7 +6181,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.5" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.6" }, "funding": [ { @@ -6193,20 +6201,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-02-21T16:25:55+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" + "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", + "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", "shasum": "" }, "require": { @@ -6248,7 +6256,7 @@ "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0", @@ -6292,7 +6300,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.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.6" }, "funding": [ { @@ -6312,20 +6320,20 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:33:42+00:00" + "time": "2026-02-26T08:30:57+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", "shasum": "" }, "require": { @@ -6376,7 +6384,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.4" + "source": "https://github.com/symfony/mailer/tree/v7.4.6" }, "funding": [ { @@ -6396,20 +6404,20 @@ "type": "tidelift" } ], - "time": "2026-01-08T08:25:11+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/mime", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" + "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", + "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", + "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", "shasum": "" }, "require": { @@ -6420,7 +6428,7 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" @@ -6428,7 +6436,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -6465,7 +6473,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.5" + "source": "https://github.com/symfony/mime/tree/v7.4.6" }, "funding": [ { @@ -6485,7 +6493,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T08:59:58+00:00" + "time": "2026-02-05T15:57:06+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7383,16 +7391,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", + "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", "shasum": "" }, "require": { @@ -7444,7 +7452,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.4" + "source": "https://github.com/symfony/routing/tree/v7.4.6" }, "funding": [ { @@ -7464,7 +7472,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:19:02+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/service-contracts", @@ -7555,16 +7563,16 @@ }, { "name": "symfony/string", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f" + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", "shasum": "" }, "require": { @@ -7622,7 +7630,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.4" + "source": "https://github.com/symfony/string/tree/v7.4.6" }, "funding": [ { @@ -7642,20 +7650,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T10:54:30+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "symfony/translation", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "bfde13711f53f549e73b06d27b35a55207528877" + "reference": "1888cf064399868af3784b9e043240f1d89d25ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/bfde13711f53f549e73b06d27b35a55207528877", - "reference": "bfde13711f53f549e73b06d27b35a55207528877", + "url": "https://api.github.com/repos/symfony/translation/zipball/1888cf064399868af3784b9e043240f1d89d25ce", + "reference": "1888cf064399868af3784b9e043240f1d89d25ce", "shasum": "" }, "require": { @@ -7722,7 +7730,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.4" + "source": "https://github.com/symfony/translation/tree/v7.4.6" }, "funding": [ { @@ -7742,7 +7750,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T10:40:19+00:00" + "time": "2026-02-17T07:53:42+00:00" }, { "name": "symfony/translation-contracts", @@ -7906,16 +7914,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", "shasum": "" }, "require": { @@ -7969,7 +7977,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" }, "funding": [ { @@ -7989,7 +7997,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T22:13:48+00:00" + "time": "2026-02-15T10:53:20+00:00" }, { "name": "thecodingmachine/safe", @@ -9160,11 +9168,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.39", + "version": "2.1.40", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", - "reference": "c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", "shasum": "" }, "require": { @@ -9209,7 +9217,7 @@ "type": "github" } ], - "time": "2026-02-11T14:48:56+00:00" + "time": "2026-02-23T15:04:35+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10831,16 +10839,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "71fd6a82fc357c8b5de22f78b228acfc43dee965" + "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/71fd6a82fc357c8b5de22f78b228acfc43dee965", - "reference": "71fd6a82fc357c8b5de22f78b228acfc43dee965", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/487ba8fa43da9a8e6503fe939b45ecd96875410e", + "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e", "shasum": "" }, "require": { @@ -10879,7 +10887,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.4" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.6" }, "funding": [ { @@ -10899,7 +10907,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T08:47:25+00:00" + "time": "2026-02-17T07:53:42+00:00" }, { "name": "theseer/tokenizer", From ec3dd856db6ed985240846179f92d9b966ceaa5e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 28 Feb 2026 18:46:05 +0000 Subject: [PATCH 153/398] Mail: Set domain for EHLO based upon the APP_URL For #5990 --- app/App/Providers/AppServiceProvider.php | 7 +++++++ tests/Unit/ConfigTest.php | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/app/App/Providers/AppServiceProvider.php b/app/App/Providers/AppServiceProvider.php index debba79446e..5264c0dccef 100644 --- a/app/App/Providers/AppServiceProvider.php +++ b/app/App/Providers/AppServiceProvider.php @@ -65,6 +65,13 @@ public function boot(): void URL::forceScheme($isHttps ? 'https' : 'http'); } + // Set SMTP mail driver to use a local domain matching the app domain, + // which helps avoid defaulting to a 127.0.0.1 domain + if ($appUrl) { + $hostName = parse_url($appUrl, PHP_URL_HOST) ?: null; + config()->set('mail.mailers.smtp.local_domain', $hostName); + } + // Allow longer string lengths after upgrade to utf8mb4 Schema::defaultStringLength(191); diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 9ed68c8bdfc..2e9190dfd36 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -122,6 +122,27 @@ public function test_mail_disable_ssl_verification_alters_mailer() }); } + public function test_app_url_changes_smtp_ehlo_host_on_mailer() + { + $getLocalDomain = function (): string { + /** @var EsmtpTransport $transport */ + $transport = Mail::mailer('smtp')->getSymfonyTransport(); + return $transport->getLocalDomain(); + }; + + $this->runWithEnv(['APP_URL' => ''], function () use ($getLocalDomain) { + $this->assertEquals('[127.0.0.1]', $getLocalDomain()); + }); + + $this->runWithEnv(['APP_URL' => 'https://example.com/cats/dogs'], function () use ($getLocalDomain) { + $this->assertEquals('example.com', $getLocalDomain()); + }); + + $this->runWithEnv(['APP_URL' => 'http://beans.cat.example.com'], function () use ($getLocalDomain) { + $this->assertEquals('beans.cat.example.com', $getLocalDomain()); + }); + } + public function test_non_null_mail_encryption_options_enforce_smtp_scheme() { $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'tls', 'mail.mailers.smtp.require_tls', true); From f2f76a3c5658456a6d1d72174defed44c7f520cb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 6 Mar 2026 09:28:46 +0000 Subject: [PATCH 154/398] Modules: Improved install command based on testing - Updated output to be clearer - Added warning and confirmation to local install flow - Adjusted module folder name creation --- app/Console/Commands/InstallModuleCommand.php | 11 +++++++++-- app/Theming/ThemeModuleManager.php | 2 +- tests/Commands/InstallModuleCommandTest.php | 14 ++++++++++++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/app/Console/Commands/InstallModuleCommand.php b/app/Console/Commands/InstallModuleCommand.php index dc3c9363e3d..20252525df8 100644 --- a/app/Console/Commands/InstallModuleCommand.php +++ b/app/Console/Commands/InstallModuleCommand.php @@ -268,7 +268,7 @@ protected function getPathToZip(string $location): string|null if ($isRemote) { // Warning about fetching from source $host = parse_url($location, PHP_URL_HOST); - $this->warn("This will download a module from {$host}. Modules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources."); + $this->warn("\nThis will download a module from: {$host}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources."); $trustHost = $this->confirm('Are you sure you trust this source?'); if (!$trustHost) { return null; @@ -286,13 +286,20 @@ protected function getPathToZip(string $location): string|null return $this->downloadModuleFile($location); } - // Validate file and get full location + // Validate the file and get the full location $zipPath = realpath($location); + if (!$zipPath || !is_file($zipPath)) { $this->error("ERROR: Module file not found at {$location}"); return null; } + $this->warn("\nThis will install a module from: {$zipPath}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources."); + $trustHost = $this->confirm('Are you sure you want to install this module?'); + if (!$trustHost) { + return null; + } + return $zipPath; } diff --git a/app/Theming/ThemeModuleManager.php b/app/Theming/ThemeModuleManager.php index 900063d47e1..86362a2f321 100644 --- a/app/Theming/ThemeModuleManager.php +++ b/app/Theming/ThemeModuleManager.php @@ -44,7 +44,7 @@ public function deleteModuleFolder(string $moduleFolderName): void */ public function addFromZip(string $name, ThemeModuleZip $zip): ThemeModule { - $baseFolderName = Str::limit(Str::slug($name), 20); + $baseFolderName = Str::limit(Str::slug($name), 40, ''); $folderName = $baseFolderName; while (!$baseFolderName || file_exists($this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName)) { $folderName = ($baseFolderName ?: 'mod') . '-' . Str::random(4); diff --git a/tests/Commands/InstallModuleCommandTest.php b/tests/Commands/InstallModuleCommandTest.php index 0872efc3f26..8ffc4ead3a0 100644 --- a/tests/Commands/InstallModuleCommandTest.php +++ b/tests/Commands/InstallModuleCommandTest.php @@ -15,6 +15,8 @@ public function test_local_module_install_with_active_theme() $zip = $this->getModuleZipPath(); $expectedInstallPath = theme_path('modules/test-module'); $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsOutput("\nThis will install a module from: {$zip}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') ->expectsOutput("Install location: {$expectedInstallPath}") ->assertExitCode(0); @@ -35,7 +37,7 @@ public function test_remote_module_install_with_active_theme() $expectedInstallPath = theme_path('modules/test-module'); $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) - ->expectsOutput("This will download a module from example.com. Modules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") + ->expectsOutput("\nThis will download a module from: example.com\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") ->expectsConfirmation('Are you sure you trust this source?', 'yes') ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') ->expectsOutput("Install location: {$expectedInstallPath}") @@ -61,7 +63,7 @@ public function test_remote_http_module_warns_and_prompts_users() $expectedInstallPath = theme_path('modules/test-module'); $this->artisan('bookstack:install-module', ['location' => 'http://example.com/test-module.zip']) - ->expectsOutput("This will download a module from example.com. Modules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") + ->expectsOutput("\nThis will download a module from: example.com\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") ->expectsConfirmation('Are you sure you trust this source?', 'yes') ->expectsOutput("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.") ->expectsConfirmation('Are you sure you want to continue without HTTPS?', 'yes') @@ -142,6 +144,7 @@ public function test_run_with_invalid_zip_has_early_exit() file_put_contents($zip, 'invalid zip'); $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') ->expectsOutput("ERROR: Cannot open ZIP file at {$zip}") ->assertExitCode(1); } @@ -153,6 +156,7 @@ public function test_run_with_large_zip_has_early_exit() ]); $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') ->expectsOutput("ERROR: Module ZIP file contents are too large. Maximum size is 50MB") ->assertExitCode(1); } @@ -166,6 +170,7 @@ public function test_run_with_invalid_module_data_has_early_exit() ]); $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') ->expectsOutput("ERROR: Failed to read module metadata with error: Module in folder \"_temp\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'") ->assertExitCode(1); } @@ -177,6 +182,7 @@ public function test_local_module_install_without_active_theme_can_setup_theme_f File::deleteDirectory($expectedThemePath); $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') ->expectsConfirmation('No active theme folder found, would you like to create one?', 'yes') ->expectsOutput("Created theme folder at {$expectedThemePath}") ->expectsOutput("You will need to set APP_THEME=custom in your BookStack env configuration to enable this theme!") @@ -195,6 +201,7 @@ public function test_local_module_install_with_active_theme_and_conflicting_modu File::put(theme_path('modules'), '{}'); $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') ->expectsOutput("ERROR: Cannot create a modules folder, file already exists at " . theme_path('modules')) ->assertExitCode(1); }); @@ -207,6 +214,7 @@ public function test_single_existing_module_with_same_name_replace() $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') ->expectsOutput('The following modules already exist with the same name:') ->expectsOutput('Test Module (test-module:v1.0.0) - cat') ->expectsChoice('What would you like to do?', 'Replace existing module', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) @@ -226,6 +234,7 @@ public function test_single_existing_module_with_same_name_cancel() $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') ->expectsOutput('The following modules already exist with the same name:') ->expectsOutput('Test Module (test-module:v1.0.0) - cat') ->expectsChoice('What would you like to do?', 'Cancel module install', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) @@ -244,6 +253,7 @@ public function test_single_existing_module_with_same_name_add() $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') ->expectsOutput('The following modules already exist with the same name:') ->expectsOutput('Test Module (test-module:v1.0.0) - cat') ->expectsChoice('What would you like to do?', 'Add alongside existing module', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) From 7d0237c798320e7383c1b5a33c23210f2f062f96 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 6 Mar 2026 10:25:27 +0000 Subject: [PATCH 155/398] NPM Deps: Updated package versions Fixed SCSS if deprecations Fixed new eslint detected issues --- package-lock.json | 2158 +++++++---------- package.json | 26 +- resources/js/wysiwyg-tinymce/plugin-drawio.js | 4 +- resources/sass/_mixins.scss | 12 +- 4 files changed, 954 insertions(+), 1246 deletions(-) diff --git a/package-lock.json b/package-lock.json index e8a1493d42f..b6508f1e9e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,62 +5,48 @@ "packages": { "": { "dependencies": { - "@codemirror/commands": "^6.10.0", + "@codemirror/commands": "^6.10.2", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", - "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-xml": "^6.1.0", - "@codemirror/language": "^6.11.3", + "@codemirror/language": "^6.12.2", "@codemirror/legacy-modes": "^6.5.2", - "@codemirror/state": "^6.5.2", + "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.38.8", + "@codemirror/view": "^6.39.16", "@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.0", + "markdown-it": "^14.1.1", "markdown-it-task-lists": "^2.1.1", "snabbdom": "^3.6.3", - "sortablejs": "^1.15.6" + "sortablejs": "^1.15.7" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^10.0.1", "@lezer/generator": "^1.8.0", "@types/markdown-it": "^14.1.2", "@types/sortablejs": "^1.15.9", "chokidar-cli": "^3.0", - "esbuild": "^0.27.0", - "eslint": "^9.39.1", - "eslint-plugin-import": "^2.32.0", + "esbuild": "^0.27.3", + "eslint": "^10.0.2", + "globals": "^17.4.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "npm-run-all": "^4.1.5", - "sass": "^1.94.2", - "ts-jest": "^29.4.5", + "sass": "^1.97.3", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "5.9.*" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -83,12 +69,12 @@ "license": "ISC" }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -97,9 +83,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -107,22 +93,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -138,14 +124,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -155,13 +141,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -182,29 +168,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -214,9 +200,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -234,9 +220,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -253,27 +239,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -338,13 +324,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -380,13 +366,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -506,13 +492,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -522,33 +508,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -556,14 +542,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -577,9 +563,9 @@ "license": "MIT" }, "node_modules/@codemirror/autocomplete": { - "version": "6.18.6", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz", - "integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==", + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -589,9 +575,9 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz", - "integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==", + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz", + "integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -631,9 +617,9 @@ } }, "node_modules/@codemirror/lang-javascript": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", - "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -698,14 +684,14 @@ } }, "node_modules/@codemirror/language": { - "version": "6.11.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", - "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", + "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" @@ -721,9 +707,9 @@ } }, "node_modules/@codemirror/lint": { - "version": "6.8.5", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz", - "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==", + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -732,20 +718,20 @@ } }, "node_modules/@codemirror/search": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", - "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "node_modules/@codemirror/state": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", - "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", + "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" @@ -764,9 +750,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.38.8", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", - "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", + "version": "6.39.16", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz", + "integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.5.0", @@ -915,9 +901,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, @@ -927,9 +913,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, @@ -949,9 +935,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", - "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -966,9 +952,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", - "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -983,9 +969,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", - "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "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==", "cpu": [ "arm64" ], @@ -1000,9 +986,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", - "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "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==", "cpu": [ "x64" ], @@ -1017,9 +1003,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", - "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -1034,9 +1020,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", - "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "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==", "cpu": [ "x64" ], @@ -1051,9 +1037,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", - "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -1068,9 +1054,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", - "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -1085,9 +1071,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", - "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -1102,9 +1088,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", - "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -1119,9 +1105,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", - "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -1136,9 +1122,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", - "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -1153,9 +1139,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", - "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -1170,9 +1156,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", - "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -1187,9 +1173,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", - "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -1204,9 +1190,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", - "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -1221,9 +1207,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", - "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -1238,9 +1224,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", - "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "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==", "cpu": [ "arm64" ], @@ -1255,9 +1241,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", - "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -1272,9 +1258,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", - "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "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==", "cpu": [ "arm64" ], @@ -1289,9 +1275,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", - "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "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==", "cpu": [ "x64" ], @@ -1306,9 +1292,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", - "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "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==", "cpu": [ "arm64" ], @@ -1323,9 +1309,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", - "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -1340,9 +1326,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", - "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -1357,9 +1343,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", - "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -1374,9 +1360,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", - "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "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==", "cpu": [ "x64" ], @@ -1391,9 +1377,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1423,9 +1409,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -1433,105 +1419,89 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "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==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "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==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.1.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", + "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", + "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", + "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.1.0", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@humanfs/core": { @@ -1545,33 +1515,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1618,62 +1574,6 @@ "node": ">=12" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1691,16 +1591,6 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1715,20 +1605,6 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -1771,16 +1647,6 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -2156,9 +2022,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { @@ -2166,6 +2032,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2177,16 +2054,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -2195,15 +2072,15 @@ } }, "node_modules/@lezer/common": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", - "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", "license": "MIT" }, "node_modules/@lezer/css": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", - "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz", + "integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2235,9 +2112,9 @@ } }, "node_modules/@lezer/html": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz", - "integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2246,9 +2123,9 @@ } }, "node_modules/@lezer/javascript": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz", - "integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2268,28 +2145,28 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", - "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" } }, "node_modules/@lezer/markdown": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz", - "integrity": "sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.0.0", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "node_modules/@lezer/php": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.4.tgz", - "integrity": "sha512-D2dJ0t8Z28/G1guztRczMFvPDUqzeMLSQbdWQmaiHV7urc8NlEOnjYk9UrZ531OcLiRxD4Ihcbv7AsDpNKDRaQ==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", + "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2328,18 +2205,18 @@ } }, "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, "dependencies": { - "detect-libc": "^1.0.3", + "detect-libc": "^2.0.3", "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">= 10.0.0" @@ -2349,25 +2226,25 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" } }, "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", "cpu": [ "arm64" ], @@ -2386,9 +2263,9 @@ } }, "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", "cpu": [ "arm64" ], @@ -2407,9 +2284,9 @@ } }, "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", "cpu": [ "x64" ], @@ -2428,9 +2305,9 @@ } }, "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", "cpu": [ "x64" ], @@ -2449,9 +2326,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", "cpu": [ "arm" ], @@ -2470,9 +2347,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", "cpu": [ "arm" ], @@ -2491,9 +2368,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", "cpu": [ "arm64" ], @@ -2512,9 +2389,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", "cpu": [ "arm64" ], @@ -2533,9 +2410,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", "cpu": [ "x64" ], @@ -2554,9 +2431,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", "cpu": [ "x64" ], @@ -2575,9 +2452,9 @@ } }, "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", "cpu": [ "arm64" ], @@ -2596,9 +2473,9 @@ } }, "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", "cpu": [ "ia32" ], @@ -2617,9 +2494,9 @@ } }, "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", "cpu": [ "x64" ], @@ -2637,6 +2514,20 @@ "url": "https://opencollective.com/parcel" } }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2661,17 +2552,10 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, "node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "license": "MIT" }, "node_modules/@sinonjs/commons": { @@ -2712,9 +2596,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, "license": "MIT" }, @@ -2795,6 +2679,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2855,13 +2746,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -2888,12 +2772,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", - "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/sortablejs": { @@ -2917,9 +2801,9 @@ "license": "MIT" }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "license": "MIT", "dependencies": { "@types/yargs-parser": "*" @@ -3208,9 +3092,9 @@ ] }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -3231,9 +3115,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", "dependencies": { @@ -3254,9 +3138,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3336,10 +3220,14 @@ "license": "MIT" }, "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", @@ -3358,21 +3246,20 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { + "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", - "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" }, "engines": { "node": ">= 0.4" @@ -3381,39 +3268,24 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" + "possible-typed-array-names": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -3422,87 +3294,20 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "30.2.0", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3589,11 +3394,27 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -3609,14 +3430,16 @@ } }, "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": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -3632,9 +3455,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -3652,10 +3475,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -3765,9 +3589,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "dev": true, "funding": [ { @@ -3856,9 +3680,9 @@ } }, "node_modules/ci-info": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", - "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "funding": [ { "type": "github", @@ -3871,9 +3695,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", - "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "dev": true, "license": "MIT" }, @@ -3899,6 +3723,68 @@ "node": ">=6" } }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cliui/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/cliui/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/cliui/node_modules/strip-ansi": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", @@ -3912,6 +3798,21 @@ "node": ">=6" } }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4088,9 +3989,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -4123,9 +4024,9 @@ "license": "MIT" }, "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4191,17 +4092,14 @@ } }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "optional": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/detect-newline": { @@ -4215,28 +4113,15 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4260,9 +4145,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.190", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz", - "integrity": "sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==", + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "dev": true, "license": "ISC" }, @@ -4280,9 +4165,9 @@ } }, "node_modules/emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, "license": "MIT" }, @@ -4299,9 +4184,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4309,9 +4194,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", "dependencies": { @@ -4426,19 +4311,6 @@ "node": ">= 0.4" } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-to-primitive": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", @@ -4458,9 +4330,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4471,32 +4343,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.0", - "@esbuild/android-arm": "0.27.0", - "@esbuild/android-arm64": "0.27.0", - "@esbuild/android-x64": "0.27.0", - "@esbuild/darwin-arm64": "0.27.0", - "@esbuild/darwin-x64": "0.27.0", - "@esbuild/freebsd-arm64": "0.27.0", - "@esbuild/freebsd-x64": "0.27.0", - "@esbuild/linux-arm": "0.27.0", - "@esbuild/linux-arm64": "0.27.0", - "@esbuild/linux-ia32": "0.27.0", - "@esbuild/linux-loong64": "0.27.0", - "@esbuild/linux-mips64el": "0.27.0", - "@esbuild/linux-ppc64": "0.27.0", - "@esbuild/linux-riscv64": "0.27.0", - "@esbuild/linux-s390x": "0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/netbsd-arm64": "0.27.0", - "@esbuild/netbsd-x64": "0.27.0", - "@esbuild/openbsd-arm64": "0.27.0", - "@esbuild/openbsd-x64": "0.27.0", - "@esbuild/openharmony-arm64": "0.27.0", - "@esbuild/sunos-x64": "0.27.0", - "@esbuild/win32-arm64": "0.27.0", - "@esbuild/win32-ia32": "0.27.0", - "@esbuild/win32-x64": "0.27.0" + "@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" } }, "node_modules/escalade": { @@ -4523,33 +4395,30 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", + "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", - "@eslint/plugin-kit": "^0.4.1", + "@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", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.1", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -4559,8 +4428,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4568,7 +4436,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -4582,125 +4450,33 @@ } } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", + "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4720,18 +4496,18 @@ } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", + "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4752,9 +4528,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4943,9 +4719,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", "dev": true, "license": "ISC" }, @@ -5045,6 +4821,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5149,6 +4935,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -5179,6 +4966,13 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "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", @@ -5190,13 +4984,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5206,9 +5000,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -5464,29 +5258,12 @@ } }, "node_modules/immutable": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", - "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "dev": true, "license": "MIT" }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -5733,13 +5510,13 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/is-generator-fn": { @@ -5753,14 +5530,15 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -6043,9 +5821,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -6254,16 +6032,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jest-cli/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/jest-cli/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -6731,9 +6499,9 @@ } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -6863,13 +6631,14 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -7096,13 +6865,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.throttle": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", @@ -7137,9 +6899,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -7167,9 +6929,9 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -7189,6 +6951,12 @@ "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==", "license": "ISC" }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7245,16 +7013,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -7268,11 +7039,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -7337,9 +7108,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "dev": true, "license": "MIT" }, @@ -7415,6 +7186,24 @@ "node": ">=4" } }, + "node_modules/npm-run-all/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/npm-run-all/node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -7484,6 +7273,19 @@ "node": ">=4" } }, + "node_modules/npm-run-all/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/npm-run-all/node_modules/path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", @@ -7567,9 +7369,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", - "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true, "license": "MIT" }, @@ -7589,79 +7391,26 @@ "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -7781,19 +7530,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -8211,13 +7947,13 @@ "license": "ISC" }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -8244,7 +7980,7 @@ "node": ">=8" } }, - "node_modules/resolve-cwd/node_modules/resolve-from": { + "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", @@ -8254,16 +7990,6 @@ "node": ">=8" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -8334,9 +8060,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.94.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz", - "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==", + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "dev": true, "license": "MIT", "dependencies": { @@ -8607,9 +8333,9 @@ } }, "node_modules/sortablejs": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", - "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", "license": "MIT" }, "node_modules/source-map": { @@ -8673,9 +8399,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", - "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, @@ -8759,18 +8485,21 @@ } }, "node_modules/string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/string-width-cjs": { @@ -8806,16 +8535,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -8829,29 +8548,6 @@ "node": ">=8" } }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/string.prototype.padend": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", @@ -8931,13 +8627,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -9004,9 +8700,9 @@ } }, "node_modules/style-mod": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", - "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, "node_modules/supports-color": { @@ -9042,9 +8738,9 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9072,11 +8768,29 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/test-exclude/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -9094,6 +8808,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -9160,9 +8887,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { @@ -9213,9 +8940,9 @@ } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -9282,42 +9009,6 @@ } } }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/tsconfig-paths/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -9441,9 +9132,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9494,9 +9185,9 @@ } }, "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unrs-resolver": { @@ -9535,9 +9226,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -9651,6 +9342,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { @@ -9775,9 +9467,9 @@ "license": "ISC" }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -9814,18 +9506,21 @@ "license": "MIT" }, "node_modules/wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrap-ansi-cjs": { @@ -9864,16 +9559,6 @@ "dev": true, "license": "MIT" }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -9902,57 +9587,17 @@ "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, "engines": { - "node": ">=4" - } - }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^4.1.0" + "node": ">=12" }, - "engines": { - "node": ">=6" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/wrappy": { @@ -9977,9 +9622,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, "license": "MIT", "engines": { @@ -10058,6 +9703,23 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true, + "license": "MIT" + }, "node_modules/yargs/node_modules/find-up": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", @@ -10071,6 +9733,16 @@ "node": ">=6" } }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/yargs/node_modules/locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -10124,6 +9796,34 @@ "node": ">=4" } }, + "node_modules/yargs/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/yargs/node_modules/yargs-parser": { "version": "13.1.2", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", diff --git a/package.json b/package.json index 624ff876a3a..45c275863b3 100644 --- a/package.json +++ b/package.json @@ -18,45 +18,45 @@ "test": "jest" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^10.0.1", "@lezer/generator": "^1.8.0", "@types/markdown-it": "^14.1.2", "@types/sortablejs": "^1.15.9", "chokidar-cli": "^3.0", - "esbuild": "^0.27.0", - "eslint": "^9.39.1", - "eslint-plugin-import": "^2.32.0", + "esbuild": "^0.27.3", + "eslint": "^10.0.2", + "globals": "^17.4.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "npm-run-all": "^4.1.5", - "sass": "^1.94.2", - "ts-jest": "^29.4.5", + "sass": "^1.97.3", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "5.9.*" }, "dependencies": { - "@codemirror/commands": "^6.10.0", + "@codemirror/commands": "^6.10.2", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", - "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-xml": "^6.1.0", - "@codemirror/language": "^6.11.3", + "@codemirror/language": "^6.12.2", "@codemirror/legacy-modes": "^6.5.2", - "@codemirror/state": "^6.5.2", + "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.38.8", + "@codemirror/view": "^6.39.16", "@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.0", + "markdown-it": "^14.1.1", "markdown-it-task-lists": "^2.1.1", "snabbdom": "^3.6.3", - "sortablejs": "^1.15.6" + "sortablejs": "^1.15.7" } } diff --git a/resources/js/wysiwyg-tinymce/plugin-drawio.js b/resources/js/wysiwyg-tinymce/plugin-drawio.js index 197c50b0e44..ad0e01800ef 100644 --- a/resources/js/wysiwyg-tinymce/plugin-drawio.js +++ b/resources/js/wysiwyg-tinymce/plugin-drawio.js @@ -57,7 +57,7 @@ async function updateContent(pngData) { }); } catch (err) { handleUploadError(err); - throw new Error(`Failed to save image with error: ${err}`); + throw new Error(`Failed to save image with error: ${err}`, {cause: err}); } return; } @@ -78,7 +78,7 @@ async function updateContent(pngData) { } catch (err) { pageEditor.dom.remove(wrapId); handleUploadError(err); - throw new Error(`Failed to save image with error: ${err}`); + throw new Error(`Failed to save image with error: ${err}`, {cause: err}); } } diff --git a/resources/sass/_mixins.scss b/resources/sass/_mixins.scss index fc508600839..e9f2db69cd9 100644 --- a/resources/sass/_mixins.scss +++ b/resources/sass/_mixins.scss @@ -35,9 +35,17 @@ // Define a property for both light and dark mode @mixin lightDark($prop, $light, $dark, $important: false) { - #{$prop}: if($important, $light !important, $light); + @if($important) { + #{$prop}: $light !important; + } @else { + #{$prop}: $light; + } html.dark-mode & { - #{$prop}: if($important, $dark !important, $dark); + @if($important) { + #{$prop}: $dark !important; + } @else { + #{$prop}: $dark; + } } } From 27240be499ef02cd04c703778f1285939992501f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 6 Mar 2026 12:40:22 +0000 Subject: [PATCH 156/398] Theme System: Added new page-content focused events Closes #6049 --- app/Entities/Tools/PageContent.php | 32 +++++++++-- app/Theming/ThemeEvents.php | 25 +++++++++ tests/Theme/LogicalThemeEventsTest.php | 76 ++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 4f72e7c490d..8d89a86cff4 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -39,7 +39,14 @@ public function __construct( public function setNewHTML(string $html, User $updater): void { $html = $this->extractBase64ImagesFromHtml($html, $updater); - $this->page->html = $this->formatHtml($html); + $html = $this->formatHtml($html); + + $themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page); + if (is_string($themeResult)) { + $html = $themeResult; + } + + $this->page->html = $html; $this->page->text = $this->toPlainText(); $this->page->markdown = ''; } @@ -52,7 +59,14 @@ public function setNewMarkdown(string $markdown, User $updater): void $markdown = $this->extractBase64ImagesFromMarkdown($markdown, $updater); $this->page->markdown = $markdown; $html = (new MarkdownToHtml($markdown))->convert(); - $this->page->html = $this->formatHtml($html); + $html = $this->formatHtml($html); + + $themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page); + if (is_string($themeResult)) { + $html = $themeResult; + } + + $this->page->html = $html; $this->page->text = $this->toPlainText(); } @@ -81,7 +95,7 @@ protected function extractBase64ImagesFromHtml(string $htmlText, User $updater): /** * Convert all inline base64 content to uploaded image files. - * Regex is used to locate the start of data-uri definitions then + * Regex is used to locate the start of data-uri definitions, then * manual looping over content is done to parse the whole data uri. * Attempting to capture the whole data uri using regex can cause PHP * PCRE limits to be hit with larger, multi-MB, files. @@ -301,7 +315,7 @@ public function render(bool $blankIncludes = false): string $html = $this->page->html ?? ''; if (empty($html)) { - return $html; + return $this->handlePostRender(''); } $doc = new HtmlDocument($html); @@ -322,7 +336,7 @@ public function render(bool $blankIncludes = false): string $cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml()); $cached = cache()->get($cacheKey, null); if ($cached !== null) { - return $cached; + return $this->handlePostRender($cached); } $filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); @@ -332,7 +346,13 @@ public function render(bool $blankIncludes = false): string $cacheTime = 86400 * 7; // 1 week cache()->put($cacheKey, $filtered, $cacheTime); - return $filtered; + return $this->handlePostRender($filtered); + } + + protected function handlePostRender(string $html): string + { + $themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_POST_RENDER, $html, $this->page); + return is_string($themeResult) ? $themeResult : $html; } protected function getContentCacheKey(string $html): string diff --git a/app/Theming/ThemeEvents.php b/app/Theming/ThemeEvents.php index 71778ec4484..511a9c1de7a 100644 --- a/app/Theming/ThemeEvents.php +++ b/app/Theming/ThemeEvents.php @@ -111,6 +111,31 @@ class ThemeEvents */ const OIDC_ID_TOKEN_PRE_VALIDATE = 'oidc_id_token_pre_validate'; + /** + * Page content post-render event. + * Runs after any display rendering of page content, typically when page content is being processed for viewing. + * Rendering typically includes parsing of page includes, and content filtering. + * Provides the HTML content about to be shown, along with the related page instance. + * If the listener returns a string value, that will be used as the HTML content instead. + * + * @param string $html + * @param \BookStack\Entities\Models\Page $page + * @return string|null + */ + const PAGE_CONTENT_POST_RENDER = 'page_content_post_render'; + + /** + * Page content pre-store event. + * Runs just before page HTML is stored in the database, after BookStack's own processing. + * Provides the HTML content about to be stored, along with the related page instance. + * If the listener returns a string value, that will be used as the HTML content instead. + * + * @param string $html + * @param \BookStack\Entities\Models\Page $page + * @return string|null + */ + const PAGE_CONTENT_PRE_STORE = 'page_content_pre_store'; + /** * Page include parse event. * Runs when a page include tag is being parsed, typically when page content is being processed for viewing. diff --git a/tests/Theme/LogicalThemeEventsTest.php b/tests/Theme/LogicalThemeEventsTest.php index 0a4afd2f4e3..2add8386899 100644 --- a/tests/Theme/LogicalThemeEventsTest.php +++ b/tests/Theme/LogicalThemeEventsTest.php @@ -215,6 +215,82 @@ public function test_activity_logged() $this->assertEquals($book->id, $args[1]->id); } + public function test_page_content_pre_store_fires_on_page_save() + { + $page = $this->entities->page(); + + $args = []; + $callback = function (...$eventArgs) use (&$args) { + $args = $eventArgs; + return '

New Content!

'; + }; + + Theme::listen(ThemeEvents::PAGE_CONTENT_PRE_STORE, $callback); + + $this->asEditor(); + $this->entities->updatePage($page, ['name' => 'My cool update page!', 'html' => '

Old content!

']); + + $this->assertCount(2, $args); + $this->assertEquals($page->id, $args[1]->id); + $this->assertEquals('

Old content!

', $args[0]); + + $newPageHtml = $page->refresh()->html; + $this->assertEquals('

New Content!

', $newPageHtml); + } + + public function test_page_content_pre_store_does_not_change_content_if_nothing_returned() + { + $page = $this->entities->page(); + Theme::listen(ThemeEvents::PAGE_CONTENT_PRE_STORE, fn() => null); + + $this->asEditor(); + $this->entities->updatePage($page, ['name' => 'My cool update page!', 'html' => '

Old content!

']); + + $newPageHtml = $page->refresh()->html; + $this->assertEquals('

Old content!

', $newPageHtml); + } + + public function test_page_content_post_render_fires_on_page_view() + { + $page = $this->entities->page(); + $page->html = '

Old content!

'; + $page->save(); + + $args = []; + $callback = function (...$eventArgs) use (&$args) { + $args = $eventArgs; + return '

New postrendercontentforyou!

'; + }; + + Theme::listen(ThemeEvents::PAGE_CONTENT_POST_RENDER, $callback); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertSee('

New postrendercontentforyou!

', false); + + $this->assertCount(2, $args); + $this->assertEquals($page->id, $args[1]->id); + $this->assertEquals('

Old content!

', $args[0]); + } + + public function test_page_content_post_render_returns_original_content_if_no_return() + { + $page = $this->entities->page(); + $page->html = '

Old content!

'; + $page->save(); + + $args = []; + $callback = function (...$eventArgs) use (&$args) { + $args = $eventArgs; + }; + + Theme::listen(ThemeEvents::PAGE_CONTENT_POST_RENDER, $callback); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertSee('

Old content!

', false); + + $this->assertCount(2, $args); + } + public function test_page_include_parse() { /** @var Page $page */ From 151823b84e227e6ed63a216ff970ce2fa5491fca Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 8 Mar 2026 10:26:00 +0000 Subject: [PATCH 157/398] Theme Modules: Added easier way to insert HTML head content --- app/Theming/CustomHtmlHeadContentProvider.php | 25 +++++++++++++++++-- app/Theming/ThemeService.php | 14 +++++++++++ dev/docs/theme-system-modules.md | 1 + .../views/layouts/parts/custom-head.blade.php | 2 +- tests/Theme/ThemeModuleTest.php | 18 +++++++++++++ 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/app/Theming/CustomHtmlHeadContentProvider.php b/app/Theming/CustomHtmlHeadContentProvider.php index 9f794a077ba..209070997a3 100644 --- a/app/Theming/CustomHtmlHeadContentProvider.php +++ b/app/Theming/CustomHtmlHeadContentProvider.php @@ -12,7 +12,8 @@ class CustomHtmlHeadContentProvider { public function __construct( protected CspService $cspService, - protected Cache $cache + protected Cache $cache, + protected ThemeService $themeService, ) { } @@ -23,8 +24,9 @@ public function __construct( public function forWeb(): string { $content = $this->getSourceContent(); - $hash = md5($content); + $hash = md5($content) . ':' . $this->themeService->getModulesHash(); $html = $this->cache->remember('custom-head-web:' . $hash, 86400, function () use ($content) { + $content .= "\n" . $this->getModuleHeadContent(); return HtmlNonceApplicator::prepare($content); }); @@ -53,4 +55,23 @@ protected function getSourceContent(): string { return setting('app-custom-head', ''); } + + /** + * Get any custom head content from installed modules. + */ + protected function getModuleHeadContent(): string + { + $content = ''; + foreach ($this->themeService->getModules() as $module) { + $headContentPath = $module->path('head'); + if (file_exists($headContentPath) && is_dir($headContentPath)) { + $htmlFiles = glob($headContentPath . '/*.html'); + foreach ($htmlFiles as $file) { + $content .= file_get_contents($file); + } + } + } + + return $content; + } } diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php index 6013bb5586d..864061c1ca6 100644 --- a/app/Theming/ThemeService.php +++ b/app/Theming/ThemeService.php @@ -126,6 +126,20 @@ public function getModules(): array return $this->modules; } + /** + * Get a hash to represent the currently loaded modules. + */ + public function getModulesHash(): string + { + $key = ""; + + foreach ($this->modules as $module) { + $key .= $module->name . ':' . $module->version . ';'; + } + + return md5($key); + } + /** * Look for a specific file within the theme or its modules. * Returns the first file found or null if not found. diff --git a/dev/docs/theme-system-modules.md b/dev/docs/theme-system-modules.md index 10eec2275d0..8aa9370ed26 100644 --- a/dev/docs/theme-system-modules.md +++ b/dev/docs/theme-system-modules.md @@ -24,6 +24,7 @@ The content within the module folder should then follow this format: - `bookstack-module.json` - REQUIRED - A JSON file containing [the metadata](#module-json-metadata) for the module. - `functions.php` - OPTIONAL - A PHP file containing code for the [logical theme system](logical-theme-system.md). +- `head/` - OPTIONAL - A folder containing HTML files which will be included into the HTML head of app-views. - `icons/` - OPTIONAL - A folder containing any icons to use as per [the visual theme system](visual-theme-system.md#customizing-icons). - `lang/` - OPTIONAL - A folder containing any language files to use as per [the visual theme system](visual-theme-system.md#customizing-text-content). - `public/` - OPTIONAL - A folder containing any files to expose into public web-space as per [the visual theme system](visual-theme-system.md#publicly-accessible-files). diff --git a/resources/views/layouts/parts/custom-head.blade.php b/resources/views/layouts/parts/custom-head.blade.php index a13215cf813..caa177030fa 100644 --- a/resources/views/layouts/parts/custom-head.blade.php +++ b/resources/views/layouts/parts/custom-head.blade.php @@ -1,6 +1,6 @@ @inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider') -@if(setting('app-custom-head') && !request()->routeIs('settings.category')) +@if(!request()->routeIs('settings.category')) {!! $headContent->forWeb() !!} diff --git a/tests/Theme/ThemeModuleTest.php b/tests/Theme/ThemeModuleTest.php index b2f912dd737..d1c7225b6af 100644 --- a/tests/Theme/ThemeModuleTest.php +++ b/tests/Theme/ThemeModuleTest.php @@ -3,6 +3,7 @@ namespace Tests\Theme; use BookStack\Facades\Theme; +use BookStack\Util\CspService; use Tests\TestCase; class ThemeModuleTest extends TestCase @@ -220,6 +221,23 @@ public function test_module_can_use_theme_view_render_functions() }); } + public function test_module_can_provide_head_content() + { + $this->usingModuleFolder(function (string $moduleFolderPath) { + mkdir($moduleFolderPath . '/head', 0777, true); + file_put_contents($moduleFolderPath . '/head/hello.html', ''); + + $this->refreshApplication(); + + $cspService = $this->app->make(CspService::class); + $nonce = $cspService->getNonce(); + + $resp = $this->asAdmin()->get('/'); + $resp->assertSee('', false); + $resp->assertSee('', false); + }); + } + protected function usingModuleFolder(callable $callback): void { $this->usingThemeFolder(function (string $themeFolder) use ($callback) { From 6d64262a61e4634285b4dedd3c49831976d1e06b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 10 Mar 2026 15:03:43 +0000 Subject: [PATCH 158/398] Revision Diffs: Added filtering post-diff render --- .../Controllers/PageRevisionController.php | 13 ++++++--- tests/Entity/PageRevisionTest.php | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index 35f1e8daf0e..4bc15e6e967 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -12,6 +12,8 @@ use BookStack\Facades\Activity; use BookStack\Http\Controller; use BookStack\Permissions\Permission; +use BookStack\Util\HtmlContentFilter; +use BookStack\Util\HtmlContentFilterConfig; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; use Ssddanbrown\HtmlDiff\Diff; @@ -101,12 +103,15 @@ public function changes(string $bookSlug, string $pageSlug, int $revisionId) $prev = $revision->getPreviousRevision(); $prevContent = $prev->html ?? ''; - $diff = Diff::excecute($prevContent, $revision->html); + + // TODO - Refactor PageContent so we can de-dupe these steps + $rawDiff = Diff::excecute($prevContent, $revision->html); + $filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); + $filter = new HtmlContentFilter($filterConfig); + $diff = $filter->filterString($rawDiff); $page->fill($revision->toArray()); - // TODO - Refactor PageContent so we don't need to juggle this - $page->html = $revision->html; - $page->html = (new PageContent($page))->render(); + $page->html = ''; $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()])); return view('pages.revision', [ diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index 3828bd06e4a..d74c1f4881d 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -47,6 +47,20 @@ public function test_page_revision_preview_shows_content_of_revision() $revisionView->assertSee('new revision content'); } + public function test_page_revision_preview_filters_html_content() + { + $this->asEditor(); + $page = $this->entities->page(); + $this->createRevisions($page, 1, ['name' => 'updated page', 'html' => '

expectthisthough

']); + $pageRevision = $page->revisions->last(); + $this->createRevisions($page, 1, ['name' => 'updated page', 'html' => '

Updated content

']); + + $revisionView = $this->get($page->getUrl() . '/revisions/' . $pageRevision->id); + $revisionView->assertStatus(200); + $revisionView->assertSee('expectthisthough'); + $revisionView->assertDontSee('dontwantthishere'); + } + public function test_page_revision_restore_updates_content() { $this->asEditor(); @@ -215,6 +229,21 @@ public function test_revision_changes_link_not_shown_for_oldest_revision() $html->assertElementContains('.item-list > .item-list-row:nth-child(2)', 'Changes'); } + public function test_page_changes_view_filters_html_content() + { + $this->asEditor(); + $page = $this->entities->page(); + $html = '

expectthisthough

'; + $this->createRevisions($page, 1, ['name' => 'updated page', 'html' => $html]); + $this->createRevisions($page, 1, ['name' => 'updated page', 'html' => $html]); + + $pageRevision = $page->revisions->last(); + $revisionView = $this->get("{$page->getUrl()}/revisions/{$pageRevision->id}/changes"); + $revisionView->assertStatus(200); + $revisionView->assertSee('expectthisthough'); + $revisionView->assertDontSee('dontwantthishere'); + } + public function test_revision_restore_action_only_visible_with_permission() { $page = $this->entities->page(); From 404e67afbc78583ba8bda7c95e3927a5b0cf5bb8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 10 Mar 2026 17:47:07 +0000 Subject: [PATCH 159/398] Page Revisions: Added testing coverage to basic diffing --- tests/Entity/PageRevisionTest.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index d74c1f4881d..132a10fa4da 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -229,7 +229,20 @@ public function test_revision_changes_link_not_shown_for_oldest_revision() $html->assertElementContains('.item-list > .item-list-row:nth-child(2)', 'Changes'); } - public function test_page_changes_view_filters_html_content() + public function test_revision_changes_view_shows_diff() + { + $this->asEditor(); + $page = $this->entities->page(); + $this->createRevisions($page, 1, ['name' => 'updated page', 'html' => '

Hello there dog

']); + $this->createRevisions($page, 1, ['name' => 'updated page', 'html' => '

Hello there cat

']); + + $pageRevision = $page->revisions()->orderBy('id', 'desc')->first(); + $revisionView = $this->get("{$page->getUrl()}/revisions/{$pageRevision->id}/changes"); + $revisionView->assertStatus(200); + $revisionView->assertSee('

Hello there dogcat

', false); + } + + public function test_revision_changes_view_filters_html_content() { $this->asEditor(); $page = $this->entities->page(); @@ -237,7 +250,7 @@ public function test_page_changes_view_filters_html_content() $this->createRevisions($page, 1, ['name' => 'updated page', 'html' => $html]); $this->createRevisions($page, 1, ['name' => 'updated page', 'html' => $html]); - $pageRevision = $page->revisions->last(); + $pageRevision = $page->revisions()->orderBy('id', 'desc')->first(); $revisionView = $this->get("{$page->getUrl()}/revisions/{$pageRevision->id}/changes"); $revisionView->assertStatus(200); $revisionView->assertSee('expectthisthough'); From 6216c89f82ed66a7b2a4aae1fda67233a554ab20 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 10 Mar 2026 17:48:12 +0000 Subject: [PATCH 160/398] Packages: Updated PHP package versions --- composer.lock | 222 +++++++++++++++++++++++++------------------------- 1 file changed, 112 insertions(+), 110 deletions(-) diff --git a/composer.lock b/composer.lock index 18d0da62191..8ee6233b8ec 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.371.2", + "version": "3.372.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "32090a8ac3ec8859cb83bdde800b8f0ecf92d8ec" + "reference": "d1885c8c5db03c2a23121e6df58ef5693df41b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/32090a8ac3ec8859cb83bdde800b8f0ecf92d8ec", - "reference": "32090a8ac3ec8859cb83bdde800b8f0ecf92d8ec", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d1885c8c5db03c2a23121e6df58ef5693df41b95", + "reference": "d1885c8c5db03c2a23121e6df58ef5693df41b95", "shasum": "" }, "require": { @@ -135,11 +135,11 @@ "authors": [ { "name": "Amazon Web Services", - "homepage": "http://aws.amazon.com" + "homepage": "https://aws.amazon.com" } ], "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", - "homepage": "http://aws.amazon.com/sdkforphp", + "homepage": "https://aws.amazon.com/sdk-for-php", "keywords": [ "amazon", "aws", @@ -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.371.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.372.2" }, - "time": "2026-02-26T19:06:10+00:00" + "time": "2026-03-09T18:21:50+00:00" }, { "name": "bacon/bacon-qr-code", @@ -635,16 +635,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.1.4", + "version": "v3.1.5", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "db712c90c5b9868df3600e64e68da62e78a34623" + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", - "reference": "db712c90c5b9868df3600e64e68da62e78a34623", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", "shasum": "" }, "require": { @@ -693,9 +693,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" }, - "time": "2025-10-29T12:43:30+00:00" + "time": "2026-03-03T13:54:37+00:00" }, { "name": "dompdf/php-font-lib", @@ -1387,16 +1387,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "shasum": "" }, "require": { @@ -1412,6 +1412,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { @@ -1483,7 +1484,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.9.0" }, "funding": [ { @@ -1499,7 +1500,7 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-03-10T16:41:02+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1800,16 +1801,16 @@ }, { "name": "laravel/framework", - "version": "v12.53.0", + "version": "v12.54.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f" + "reference": "e908e117421bcade301b174aba2d3e8cc1e1f213" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f57f035c0d34503d9ff30be76159bb35a003cd1f", - "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f", + "url": "https://api.github.com/repos/laravel/framework/zipball/e908e117421bcade301b174aba2d3e8cc1e1f213", + "reference": "e908e117421bcade301b174aba2d3e8cc1e1f213", "shasum": "" }, "require": { @@ -1830,7 +1831,7 @@ "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.7", + "league/commonmark": "^2.8.1", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", @@ -2018,20 +2019,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-24T14:35:15+00:00" + "time": "2026-03-10T15:30:40+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.13", + "version": "v0.3.14", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" + "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", - "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", + "url": "https://api.github.com/repos/laravel/prompts/zipball/9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", + "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", "shasum": "" }, "require": { @@ -2075,9 +2076,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.13" + "source": "https://github.com/laravel/prompts/tree/v0.3.14" }, - "time": "2026-02-06T12:17:10+00:00" + "time": "2026-03-01T09:02:38+00:00" }, { "name": "laravel/serializable-closure", @@ -2142,16 +2143,16 @@ }, { "name": "laravel/socialite", - "version": "v5.24.3", + "version": "v5.25.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "0feb62267e7b8abc68593ca37639ad302728c129" + "reference": "231f572e1a37c9ca1fb8085e9fb8608285beafb3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/0feb62267e7b8abc68593ca37639ad302728c129", - "reference": "0feb62267e7b8abc68593ca37639ad302728c129", + "url": "https://api.github.com/repos/laravel/socialite/zipball/231f572e1a37c9ca1fb8085e9fb8608285beafb3", + "reference": "231f572e1a37c9ca1fb8085e9fb8608285beafb3", "shasum": "" }, "require": { @@ -2210,7 +2211,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-02-21T13:32:50+00:00" + "time": "2026-02-27T13:56:35+00:00" }, { "name": "laravel/tinker", @@ -2280,16 +2281,16 @@ }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "84b1ca48347efdbe775426f108622a42735a6579" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579", + "reference": "84b1ca48347efdbe775426f108622a42735a6579", "shasum": "" }, "require": { @@ -2314,9 +2315,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -2383,7 +2384,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-03-05T21:37:03+00:00" }, { "name": "league/config", @@ -4188,16 +4189,16 @@ }, { "name": "predis/predis", - "version": "v3.4.1", + "version": "v3.4.2", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "0850f2f36ee179f0ff96c92c750e1366c6cd754c" + "reference": "2033429520d8997a7815a2485f56abe6d2d0e075" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/0850f2f36ee179f0ff96c92c750e1366c6cd754c", - "reference": "0850f2f36ee179f0ff96c92c750e1366c6cd754c", + "url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075", + "reference": "2033429520d8997a7815a2485f56abe6d2d0e075", "shasum": "" }, "require": { @@ -4239,7 +4240,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v3.4.1" + "source": "https://github.com/predis/predis/tree/v3.4.2" }, "funding": [ { @@ -4247,7 +4248,7 @@ "type": "github" } ], - "time": "2026-02-23T19:51:21+00:00" + "time": "2026-03-09T20:33:04+00:00" }, { "name": "psr/clock", @@ -4663,16 +4664,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.20", + "version": "v0.12.21", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" + "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", - "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", + "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", "shasum": "" }, "require": { @@ -4736,9 +4737,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.21" }, - "time": "2026-02-11T15:05:28+00:00" + "time": "2026-03-06T21:21:28+00:00" }, { "name": "ralouphie/getallheaders", @@ -4982,16 +4983,16 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v9.2.0", + "version": "v9.3.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "59373045e11ad47b5c18fc615feee0219e42f6d3" + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/59373045e11ad47b5c18fc615feee0219e42f6d3", - "reference": "59373045e11ad47b5c18fc615feee0219e42f6d3", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", "shasum": "" }, "require": { @@ -5018,7 +5019,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.3.x-dev" + "dev-main": "9.4.x-dev" } }, "autoload": { @@ -5056,9 +5057,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.2.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" }, - "time": "2026-02-21T17:12:03+00:00" + "time": "2026-03-03T17:31:43+00:00" }, { "name": "socialiteproviders/discord", @@ -5508,16 +5509,16 @@ }, { "name": "symfony/console", - "version": "v7.4.6", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d643a93b47398599124022eb24d97c153c12f27" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27", - "reference": "6d643a93b47398599124022eb24d97c153c12f27", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "shasum": "" }, "require": { @@ -5582,7 +5583,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.6" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -5602,7 +5603,7 @@ "type": "tidelift" } ], - "time": "2026-02-25T17:02:47+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/css-selector", @@ -6123,16 +6124,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.6", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065" + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065", - "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", "shasum": "" }, "require": { @@ -6181,7 +6182,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.6" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" }, "funding": [ { @@ -6201,20 +6202,20 @@ "type": "tidelift" } ], - "time": "2026-02-21T16:25:55+00:00" + "time": "2026-03-06T13:15:18+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.6", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83" + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", - "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", "shasum": "" }, "require": { @@ -6300,7 +6301,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.6" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" }, "funding": [ { @@ -6320,7 +6321,7 @@ "type": "tidelift" } ], - "time": "2026-02-26T08:30:57+00:00" + "time": "2026-03-06T16:33:18+00:00" }, { "name": "symfony/mailer", @@ -6408,16 +6409,16 @@ }, { "name": "symfony/mime", - "version": "v7.4.6", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f" + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", - "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", + "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", "shasum": "" }, "require": { @@ -6473,7 +6474,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.6" + "source": "https://github.com/symfony/mime/tree/v7.4.7" }, "funding": [ { @@ -6493,7 +6494,7 @@ "type": "tidelift" } ], - "time": "2026-02-05T15:57:06+00:00" + "time": "2026-03-05T15:24:09+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8721,40 +8722,40 @@ }, { "name": "larastan/larastan", - "version": "v3.9.2", + "version": "v3.9.3", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2" + "reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2", - "reference": "2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2", + "url": "https://api.github.com/repos/larastan/larastan/zipball/64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65", + "reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65", "shasum": "" }, "require": { "ext-json": "*", "iamcal/sql-parser": "^0.7.0", - "illuminate/console": "^11.44.2 || ^12.4.1", - "illuminate/container": "^11.44.2 || ^12.4.1", - "illuminate/contracts": "^11.44.2 || ^12.4.1", - "illuminate/database": "^11.44.2 || ^12.4.1", - "illuminate/http": "^11.44.2 || ^12.4.1", - "illuminate/pipeline": "^11.44.2 || ^12.4.1", - "illuminate/support": "^11.44.2 || ^12.4.1", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "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" }, "require-dev": { "doctrine/coding-standard": "^13", - "laravel/framework": "^11.44.2 || ^12.7.2", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", "mockery/mockery": "^1.6.12", "nikic/php-parser": "^5.4", - "orchestra/canvas": "^v9.2.2 || ^10.0.1", - "orchestra/testbench-core": "^9.12.0 || ^10.1", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", "phpstan/phpstan-deprecation-rules": "^2.0.1", - "phpunit/phpunit": "^10.5.35 || ^11.5.15" + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8" }, "suggest": { "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", @@ -8799,7 +8800,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.9.2" + "source": "https://github.com/larastan/larastan/tree/v3.9.3" }, "funding": [ { @@ -8807,7 +8808,7 @@ "type": "github" } ], - "time": "2026-01-30T15:16:32+00:00" + "time": "2026-02-20T12:07:12+00:00" }, { "name": "mockery/mockery", @@ -10743,21 +10744,22 @@ }, { "name": "ssddanbrown/asserthtml", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://codeberg.org/danb/asserthtml", - "reference": "cf8206171d667d43e1bdde17d67191f30e95c8a0" + "reference": "0811b5c8d541f344c193bd7f2c2d79d13d23b141" }, "dist": { "type": "zip", - "url": "https://codeberg.org/api/v1/repos/danb/asserthtml/archive/%prettyVersion%.zip" + "url": "https://codeberg.org/api/v1/repos/danb/asserthtml/archive/%prettyVersion%.zip", + "reference": "0811b5c8d541f344c193bd7f2c2d79d13d23b141" }, "require": { "ext-dom": "*", "ext-json": "*", "php": ">=8.1", - "phpunit/phpunit": "^10.0|^11.0", + "phpunit/phpunit": "^10.0|^11.0|^12.0|^13.0", "symfony/css-selector": "^6.0|^7.0", "symfony/dom-crawler": "^6.0|^7.0" }, @@ -10783,7 +10785,7 @@ ], "description": "HTML Content Assertions for PHPUnit", "homepage": "https://codeberg.org/danb/asserthtml", - "time": "2025-01-11T13:35:55+00:00" + "time": "2026-03-04T14:19:44+00:00" }, { "name": "staabm/side-effects-detector", From 6e7cc169d1527c18556d536159e0e23df85c381e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 10 Mar 2026 18:31:51 +0000 Subject: [PATCH 161/398] Preferences: Updated return redirect with better origin checks As suggested by Alex Dan in their security report. --- app/Http/Controller.php | 16 ++++++++++++++-- tests/User/UserPreferencesTest.php | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 5d3be4951ca..1a0f5932e6f 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -167,14 +167,26 @@ protected function getImageValidationRules(): array /** * Redirect to the URL provided in the request as a '_return' parameter. - * Will check that the parameter leads to a URL under the root path of the system. + * Will check that the parameter leads to a URL under the same origin as the application. */ protected function redirectToRequest(Request $request): RedirectResponse { $basePath = url('/'); $returnUrl = $request->input('_return') ?? $basePath; - if (!str_starts_with($returnUrl, $basePath)) { + // Only allow use of _return on requests where we expect CSRF to be active + // to prevent it potentially being used as an open redirect + $allowedMethods = ['POST', 'PUT', 'PATCH', 'DELETE']; + if (!in_array($request->getMethod(), $allowedMethods)) { + return redirect($basePath); + } + + $intendedUrl = parse_url($returnUrl); + $baseUrl = parse_url($basePath); + $isSameOrigin = ($intendedUrl['host'] ?? '') === ($baseUrl['host'] ?? '') + && ($intendedUrl['scheme'] ?? '') === ($baseUrl['scheme'] ?? '') + && ($intendedUrl['port'] ?? 0) === ($baseUrl['port'] ?? 0); + if (!$isSameOrigin) { return redirect($basePath); } diff --git a/tests/User/UserPreferencesTest.php b/tests/User/UserPreferencesTest.php index ff3cb63ca70..e893f002dfd 100644 --- a/tests/User/UserPreferencesTest.php +++ b/tests/User/UserPreferencesTest.php @@ -153,6 +153,26 @@ public function test_shelf_view_type_change() ->assertElementNotExists('.content-wrap .entity-list-item'); } + public function test_redirect_on_preference_change_checks_host() + { + $expectedByRedirect = [ + 'http://localhost/beans' => 'http://localhost/beans', + 'https://localhost/beans' => 'http://localhost', + 'http://localhost:9090/beans' => 'http://localhost', + 'http://localhost.example.com/beans' => 'http://localhost', + 'http://localhost@example.com/beans' => 'http://localhost', + ]; + + $this->asEditor(); + foreach ($expectedByRedirect as $url => $expected) { + $req = $this->patch("/preferences/change-view/bookshelf", [ + 'view' => 'grid', + '_return' => $url, + ]); + $req->assertRedirect($expected); + } + } + public function test_update_code_language_favourite() { $editor = $this->users->editor(); From e3fcd26f12ccda464cb22bc4e95bad108878bca0 Mon Sep 17 00:00:00 2001 From: Claudio Valdez Date: Wed, 11 Mar 2026 12:30:59 -0300 Subject: [PATCH 162/398] 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 494221b143e..6d1a47c0de7 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -208,4 +208,17 @@ public function destroy(Request $request, int $id) 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 c4d1eb136eb..8499aed6f90 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -263,6 +263,11 @@ '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 611653d6a80..64d45f50361 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -71,6 +71,26 @@ class="button outline">{{ trans('settings.users_mfa_configure') }} + @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 a20c0a3d3d0..2571da2f3a2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -251,6 +251,7 @@ 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 5f5fea7c83992cecca61b4aabfc1e07053dfa4c4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 12 Mar 2026 10:52:12 +0000 Subject: [PATCH 163/398] Deps: Bumped PHP packages before release --- composer.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/composer.lock b/composer.lock index 8ee6233b8ec..d8ea0066265 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.372.2", + "version": "3.373.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "d1885c8c5db03c2a23121e6df58ef5693df41b95" + "reference": "fb74a2dca7ae2363e929c5cea33a4a4db0d22690" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d1885c8c5db03c2a23121e6df58ef5693df41b95", - "reference": "d1885c8c5db03c2a23121e6df58ef5693df41b95", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/fb74a2dca7ae2363e929c5cea33a4a4db0d22690", + "reference": "fb74a2dca7ae2363e929c5cea33a4a4db0d22690", "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.372.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.373.0" }, - "time": "2026-03-09T18:21:50+00:00" + "time": "2026-03-11T18:33:36+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1801,16 +1801,16 @@ }, { "name": "laravel/framework", - "version": "v12.54.0", + "version": "v12.54.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "e908e117421bcade301b174aba2d3e8cc1e1f213" + "reference": "325497463e7599cd14224c422c6e5dd2fe832868" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/e908e117421bcade301b174aba2d3e8cc1e1f213", - "reference": "e908e117421bcade301b174aba2d3e8cc1e1f213", + "url": "https://api.github.com/repos/laravel/framework/zipball/325497463e7599cd14224c422c6e5dd2fe832868", + "reference": "325497463e7599cd14224c422c6e5dd2fe832868", "shasum": "" }, "require": { @@ -2019,7 +2019,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-10T15:30:40+00:00" + "time": "2026-03-10T20:25:56+00:00" }, { "name": "laravel/prompts", @@ -3361,16 +3361,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.1", + "version": "3.11.3", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f" + "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", + "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", "shasum": "" }, "require": { @@ -3462,7 +3462,7 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:26:29+00:00" + "time": "2026-03-11T17:23:39+00:00" }, { "name": "nette/schema", From 60a3b0c0acb9685fb303b25f503b690cb2083213 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 12 Mar 2026 17:04:36 +0000 Subject: [PATCH 164/398] API examples: Updated books-read to include shelf info --- dev/api/responses/books-read.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json index 582744f99a5..0c8ff473df5 100644 --- a/dev/api/responses/books-read.json +++ b/dev/api/responses/books-read.json @@ -79,5 +79,17 @@ "path": "/uploads/images/cover_book/2020-01/sjovall_m117hUWMu40.jpg", "type": "cover_book", "uploaded_to": 16 - } + }, + "shelves": [ + { + "id": 1, + "name": "Great reads", + "slug": "great-reads" + }, + { + "id": 5, + "name": "Personal Books", + "slug": "personal-books" + } + ] } \ No newline at end of file From f4c9d2b0492d5a2dc3de052352f7a2e90d150f3f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 13 Mar 2026 13:35:28 +0000 Subject: [PATCH 165/398] Exports: Fixed scope of pages in chapter MD export Added tests to cover children of all MD exports --- app/Exports/ExportFormatter.php | 2 +- tests/Exports/MarkdownExportTest.php | 46 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/Exports/ExportFormatter.php b/app/Exports/ExportFormatter.php index ad489aba1cb..c5973eace29 100644 --- a/app/Exports/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -323,7 +323,7 @@ public function chapterToMarkdown(Chapter $chapter): string $text .= $description . "\n\n"; } - foreach ($chapter->pages as $page) { + foreach ($chapter->getVisiblePages() as $page) { $text .= $this->pageToMarkdown($page) . "\n\n"; } diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php index 6bf585d5903..09928ced29e 100644 --- a/tests/Exports/MarkdownExportTest.php +++ b/tests/Exports/MarkdownExportTest.php @@ -56,6 +56,20 @@ public function test_chapter_markdown_export() $resp->assertSee('My **chapter** description'); } + public function test_chapter_markdown_export_pages_are_permission_controlled() + { + $chapter = $this->entities->chapterHasPages(); + $page = $chapter->pages()->first(); + $page->name = 'MyPageWhichShouldNotBeFound'; + $page->save(); + $this->permissions->disableEntityInheritedPermissions($page); + + $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown')); + + $resp->assertSee('# ' . $chapter->name); + $resp->assertDontSee('MyPageWhichShouldNotBeFound'); + } + public function test_book_markdown_export() { $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); @@ -76,6 +90,38 @@ public function test_book_markdown_export() $resp->assertSee('My **chapter** description'); } + public function test_book_markdown_export_chapters_are_permission_controlled() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + $page->name = 'MyPageWhichShouldNotBeFound'; + $page->save(); + $chapter->name = 'MyChapterWhichShouldNotBeFound'; + $chapter->save(); + $this->permissions->disableEntityInheritedPermissions($chapter); + + $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); + + $resp->assertSee('# ' . $book->name); + $resp->assertDontSee('MyChapterWhichShouldNotBeFound'); + $resp->assertDontSee('MyPageWhichShouldNotBeFound'); + } + + public function test_book_markdown_export_direct_pages_are_permission_controlled() + { + $book = $this->entities->bookHasChaptersAndPages(); + $page = $book->directPages()->first(); + $page->name = 'MyPageWhichShouldNotBeFound'; + $page->save(); + $this->permissions->disableEntityInheritedPermissions($page); + + $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); + + $resp->assertSee('# ' . $book->name); + $resp->assertDontSee('MyPageWhichShouldNotBeFound'); + } + public function test_book_markdown_export_concats_immediate_pages_with_newlines() { /** @var Book $book */ From 7cbfd72920faf7e53c5b681119ac88adada811c3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Mar 2026 12:58:05 +0000 Subject: [PATCH 166/398] Merge pull request #6007 from BookStackApp/l10n_development Updated translations with latest Crowdin changes --- lang/ar/settings.php | 2 +- lang/bg/settings.php | 2 +- lang/bn/settings.php | 2 +- lang/bs/settings.php | 2 +- lang/ca/settings.php | 2 +- lang/cs/errors.php | 2 +- lang/cs/settings.php | 2 +- lang/cy/settings.php | 2 +- lang/da/settings.php | 2 +- lang/de/settings.php | 2 +- lang/de_informal/settings.php | 2 +- lang/el/settings.php | 2 +- lang/es/errors.php | 2 +- lang/es/settings.php | 2 +- lang/es_AR/errors.php | 2 +- lang/es_AR/settings.php | 2 +- lang/et/errors.php | 2 +- lang/et/settings.php | 2 +- lang/eu/settings.php | 2 +- lang/fa/settings.php | 2 +- lang/fi/settings.php | 2 +- lang/fr/errors.php | 4 ++-- lang/fr/settings.php | 2 +- lang/he/settings.php | 2 +- lang/hr/settings.php | 2 +- lang/hu/settings.php | 2 +- lang/id/settings.php | 2 +- lang/is/settings.php | 2 +- lang/it/errors.php | 2 +- lang/it/settings.php | 2 +- lang/ja/errors.php | 2 +- lang/ja/settings.php | 2 +- lang/ka/settings.php | 2 +- lang/ko/settings.php | 2 +- lang/ku/settings.php | 2 +- lang/lt/settings.php | 2 +- lang/lv/settings.php | 2 +- lang/nb/settings.php | 2 +- lang/ne/settings.php | 2 +- lang/nl/settings.php | 2 +- lang/nn/settings.php | 2 +- lang/pl/settings.php | 2 +- lang/pt/settings.php | 2 +- lang/pt_BR/errors.php | 4 ++-- lang/pt_BR/notifications.php | 4 ++-- lang/pt_BR/preferences.php | 2 +- lang/pt_BR/settings.php | 6 +++--- lang/pt_BR/validation.php | 2 +- lang/ro/settings.php | 2 +- lang/ru/entities.php | 38 +++++++++++++++++------------------ lang/ru/notifications.php | 2 +- lang/ru/settings.php | 6 +++--- lang/sk/settings.php | 2 +- lang/sl/settings.php | 2 +- lang/sq/settings.php | 2 +- lang/sr/settings.php | 2 +- lang/sv/entities.php | 2 +- lang/sv/preferences.php | 2 +- lang/sv/settings.php | 2 +- lang/tk/settings.php | 2 +- lang/tr/settings.php | 2 +- lang/uk/settings.php | 2 +- lang/uz/settings.php | 2 +- lang/vi/settings.php | 2 +- lang/zh_CN/editor.php | 2 +- lang/zh_CN/entities.php | 10 ++++----- lang/zh_CN/errors.php | 4 ++-- lang/zh_CN/notifications.php | 4 ++-- lang/zh_CN/preferences.php | 2 +- lang/zh_CN/settings.php | 12 +++++------ lang/zh_CN/validation.php | 2 +- lang/zh_TW/settings.php | 2 +- 72 files changed, 108 insertions(+), 108 deletions(-) diff --git a/lang/ar/settings.php b/lang/ar/settings.php index dc95ac8468f..3191bbe3a0a 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'الفصول الأولى', 'sort_rule_op_chapters_last' => 'الفصول الأخيرة', 'sorting_page_limits' => 'حدود العرض لكل صفحة', - 'sorting_page_limits_desc' => 'تعيين عدد العناصر لإظهار كل صفحة في قوائم مختلفة داخل النظام. عادةً ما يكون الرقم الأقل هو الأكثر أداء، بينما وضع رقم أعلى يغني عن النقر على صفحات متعددة. يوصى باستخدام مضاعفات رقم ٣ (18 و 24 و 30 و إلخ...).', + '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 'maint' => 'الصيانة', diff --git a/lang/bg/settings.php b/lang/bg/settings.php index ae770c559cf..a1297e44613 100644 --- a/lang/bg/settings.php +++ b/lang/bg/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Поддръжка', diff --git a/lang/bn/settings.php b/lang/bn/settings.php index 6d0f4ab88b2..94ad059d4ce 100644 --- a/lang/bn/settings.php +++ b/lang/bn/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Maintenance', diff --git a/lang/bs/settings.php b/lang/bs/settings.php index c68605fe1f8..c4d1eb136eb 100644 --- a/lang/bs/settings.php +++ b/lang/bs/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Maintenance', diff --git a/lang/ca/settings.php b/lang/ca/settings.php index 352291fe5b5..a890b9809d4 100644 --- a/lang/ca/settings.php +++ b/lang/ca/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Capítols a l\'inici', 'sort_rule_op_chapters_last' => 'Capítols al final', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Manteniment', diff --git a/lang/cs/errors.php b/lang/cs/errors.php index 17632064208..5d086456ba1 100644 --- a/lang/cs/errors.php +++ b/lang/cs/errors.php @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'Poskytnutý Token Secret neodpovídá použitému API tokenu', 'api_user_no_api_permission' => 'Vlastník použitého API tokenu nemá oprávnění provádět API volání', 'api_user_token_expired' => 'Platnost autorizačního tokenu vypršela', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Při používání API s ověřováním pomocí souborů cookie jsou povoleny pouze požadavky GET', // Settings & Maintenance 'maintenance_test_email_failure' => 'Při posílání testovacího e-mailu nastala chyba:', diff --git a/lang/cs/settings.php b/lang/cs/settings.php index 73ba6bfb077..ef25f1a2035 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Kapitoly jako první', 'sort_rule_op_chapters_last' => 'Kapitoly jako poslední', 'sorting_page_limits' => 'Počet zobrazených položek na stránce', - 'sorting_page_limits_desc' => 'Nastavte, kolik položek se má zobrazit na stránce v různých seznamech na webu. Obvykle bude nižší počet výkonnější, zatímco vyšší počet eliminuje nutnost proklikávat se několika stránkami. Doporučuje se použít sudý násobek čísla 3 (18, 24, 30 atd.).', + '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 'maint' => 'Údržba', diff --git a/lang/cy/settings.php b/lang/cy/settings.php index 29e86e28bb5..f4fbf0bba1b 100644 --- a/lang/cy/settings.php +++ b/lang/cy/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Cynnal', diff --git a/lang/da/settings.php b/lang/da/settings.php index 2f161ed4fe4..cd869c62fe0 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -104,7 +104,7 @@ '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' => 'Angiv, hvor mange elementer der skal vises pr. side i forskellige lister i systemet. Typisk vil et lavere beløb være mere effektivt, mens et højere beløb undgår behovet for at klikke sig igennem flere sider. Det anbefales at bruge et lige multiplum af 3 (18, 24, 30 osv.).', + '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 'maint' => 'Vedligeholdelse', diff --git a/lang/de/settings.php b/lang/de/settings.php index a874089958a..e57ac52c457 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -105,7 +105,7 @@ '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Wartung', diff --git a/lang/de_informal/settings.php b/lang/de_informal/settings.php index ab9a075a0a7..fa175187b80 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -105,7 +105,7 @@ '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Wartung', diff --git a/lang/el/settings.php b/lang/el/settings.php index 67461604eb0..6ec5c4fddeb 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Συντήρηση', diff --git a/lang/es/errors.php b/lang/es/errors.php index 76581257e8c..25f82a5d872 100644 --- a/lang/es/errors.php +++ b/lang/es/errors.php @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'El secreto proporcionado para el token API usado es incorrecto', 'api_user_no_api_permission' => 'El propietario del token API usado no tiene permiso para hacer llamadas API', 'api_user_token_expired' => 'El token de autorización usado ha caducado', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Sólo se permiten peticiones GET cuando se utiliza el API con autenticación basada en cookies', // Settings & Maintenance 'maintenance_test_email_failure' => 'Error al enviar un email de prueba:', diff --git a/lang/es/settings.php b/lang/es/settings.php index 1a9927c8eca..bfd3ce1cfe4 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Capítulos al inicio', 'sort_rule_op_chapters_last' => 'Capítulos al final', 'sorting_page_limits' => 'Límites de visualización por página', - 'sorting_page_limits_desc' => 'Establecer cuántos elementos a mostrar por página en varias listas dentro del sistema. Normalmente una cantidad más baja rendirá mejor, mientras que una cantidad más alta evita la necesidad de hacer clic a través de varias páginas. Se recomienda utilizar un múltiplo par de 3 (18, 24, 30, etc).', + 'sorting_page_limits_desc' => 'Establecer cuántos elementos a mostrar por página en varias listas dentro del sistema. Normalmente una cantidad más baja rendirá mejor, mientras que una cantidad más alta evita la necesidad de hacer clic a través de varias páginas. Se recomienda utilizar un múltiplo de 6.', // Maintenance settings 'maint' => 'Mantenimiento', diff --git a/lang/es_AR/errors.php b/lang/es_AR/errors.php index 5e052c51d5a..6f6b25bcf98 100644 --- a/lang/es_AR/errors.php +++ b/lang/es_AR/errors.php @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'El secreto proporcionado para el token API usado es incorrecto', 'api_user_no_api_permission' => 'El propietario del token API usado no tiene permiso para hacer llamadas API', 'api_user_token_expired' => 'El token de autorización usado ha caducado', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Sólo se permiten peticiones GET cuando se utiliza el API con autenticación basada en cookies', // Settings & Maintenance 'maintenance_test_email_failure' => 'Error al enviar un email de prueba:', diff --git a/lang/es_AR/settings.php b/lang/es_AR/settings.php index 3eb41d2cc91..90f43a6f268 100644 --- a/lang/es_AR/settings.php +++ b/lang/es_AR/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Capítulos al inicio', 'sort_rule_op_chapters_last' => 'Capítulos al final', 'sorting_page_limits' => 'Límites de visualización por página', - 'sorting_page_limits_desc' => 'Establecer cuántos elementos a mostrar por página en varias listas dentro del sistema. Normalmente una cantidad más baja rendirá mejor, mientras que una cantidad más alta evita la necesidad de hacer clic a través de varias páginas. Se recomienda utilizar un múltiplo par de 3 (18, 24, 30, etc).', + 'sorting_page_limits_desc' => 'Establecer cuántos elementos a mostrar por página en varias listas dentro del sistema. Normalmente una cantidad más baja rendirá mejor, mientras que una cantidad más alta evita la necesidad de hacer clic a través de varias páginas. Se recomienda utilizar un múltiplo de 6.', // Maintenance settings 'maint' => 'Mantenimiento', diff --git a/lang/et/errors.php b/lang/et/errors.php index c9b9fbb4e8a..78a9d8c47fb 100644 --- a/lang/et/errors.php +++ b/lang/et/errors.php @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'API tunnusele lisatud salajane võti ei ole korrektne', 'api_user_no_api_permission' => 'Selle API tunnuse omanikul ei ole õigust API päringuid teha', 'api_user_token_expired' => 'Volitustunnus on aegunud', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Küpsistega autentimisel on API kasutamisel lubatud ainult GET päringud', // Settings & Maintenance 'maintenance_test_email_failure' => 'Test e-kirja saatmisel tekkis viga:', diff --git a/lang/et/settings.php b/lang/et/settings.php index fbd9d9c7e7c..bc5a7794e87 100644 --- a/lang/et/settings.php +++ b/lang/et/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Peatükid eespool', 'sort_rule_op_chapters_last' => 'Peatükid tagapool', 'sorting_page_limits' => 'Leheküljepõhised kuvalimiidid', - 'sorting_page_limits_desc' => 'Seadista, mitut objekti erinevates loendites ühel leheküljel kuvada. Väiksem väärtus tähendab reeglina paremat jõudlust, samas kui suurem väärtus vähendab vajadust mitut lehekülge läbi klikkida. Soovituslik on kasutada 3-ga jaguvat väärtust (18, 24, 30 jne).', + 'sorting_page_limits_desc' => 'Vali, mitu objekti erinevates nimekirjades ühel lehel kuvada. Madalam väärtus tähendab reeglina paremat jõudlust, samas kui kõrgem väärtus väldib vajadust mitmeid lehti läbi klikkida. Soovituslik on kasutada 6-ga jaguvat väärtust.', // Maintenance settings 'maint' => 'Hooldus', diff --git a/lang/eu/settings.php b/lang/eu/settings.php index 9a9227b8303..0f764dccbd2 100644 --- a/lang/eu/settings.php +++ b/lang/eu/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Mantentze-lanak', diff --git a/lang/fa/settings.php b/lang/fa/settings.php index abbfce470c3..2fa11511838 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'ابتدا فصل‌ها', 'sort_rule_op_chapters_last' => 'فصل‌ها در آخر', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'نگهداری', diff --git a/lang/fi/settings.php b/lang/fi/settings.php index 499122fe35b..adc47fe2d87 100644 --- a/lang/fi/settings.php +++ b/lang/fi/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Huolto', diff --git a/lang/fr/errors.php b/lang/fr/errors.php index a5a3614c2f9..b74fb5465db 100644 --- a/lang/fr/errors.php +++ b/lang/fr/errors.php @@ -13,7 +13,7 @@ 'auth_pre_register_theme_prevention' => 'Le compte utilisateur n\'a pas pu être enregistré avec les informations fournies', 'email_already_confirmed' => 'Cet e-mail a déjà été validé, vous pouvez vous connecter.', 'email_confirmation_invalid' => 'Cette confirmation est invalide. Veuillez essayer de vous inscrire à nouveau.', - 'email_confirmation_expired' => 'Le jeton de confirmation est périmé. Un nouvel e-mail vous a été envoyé.', + 'email_confirmation_expired' => 'Le jeton de confirmation a expiré. Un nouvel e-mail vous a été envoyé.', 'email_confirmation_awaiting' => 'L\'adresse e-mail du compte utilisé doit être confirmée', 'ldap_fail_anonymous' => 'L\'accès LDAP anonyme n\'a pas abouti', 'ldap_fail_authed' => 'L\'accès LDAP n\'a pas abouti avec cet utilisateur et ce mot de passe', @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'Le secret fourni pour le jeton d\'API utilisé est incorrect', 'api_user_no_api_permission' => 'Le propriétaire du jeton API utilisé n\'a pas la permission de passer des requêtes API', 'api_user_token_expired' => 'Le jeton d\'autorisation utilisé a expiré', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Seules les requêtes GET sont autorisées lors de l’utilisation de l’API avec une authentification basée sur les cookies', // Settings & Maintenance 'maintenance_test_email_failure' => 'Erreur émise lors de l\'envoi d\'un e-mail de test :', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index 317e777b972..7b0987a488c 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapitres en premier', 'sort_rule_op_chapters_last' => 'Chapitres en dernier', 'sorting_page_limits' => 'Limite d\'affichage par page', - 'sorting_page_limits_desc' => 'Définissez le nombre d’éléments à afficher par page dans les différentes listes du système. En général, un nombre plus faible offre de meilleures performances, tandis qu’un nombre plus élevé réduit le besoin de naviguer entre plusieurs pages. Il est recommandé d’utiliser un multiple pair de 3 (18, 24, 30, etc.).', + '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 'maint' => 'Maintenance', diff --git a/lang/he/settings.php b/lang/he/settings.php index 0b5034475b9..46150081aa6 100644 --- a/lang/he/settings.php +++ b/lang/he/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'תחזוקה', diff --git a/lang/hr/settings.php b/lang/hr/settings.php index 6465d0ea72e..0692c8d7a13 100644 --- a/lang/hr/settings.php +++ b/lang/hr/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Održavanje', diff --git a/lang/hu/settings.php b/lang/hu/settings.php index c6810ed6838..53b1cdcc424 100644 --- a/lang/hu/settings.php +++ b/lang/hu/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Karbantartás', diff --git a/lang/id/settings.php b/lang/id/settings.php index cc942668307..8bdd99e6890 100644 --- a/lang/id/settings.php +++ b/lang/id/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Bab di Urutan Pertama', 'sort_rule_op_chapters_last' => 'Bab di Urutan Terakhir', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Pemeliharaan', diff --git a/lang/is/settings.php b/lang/is/settings.php index 5699c88d623..b1f21ac10b8 100644 --- a/lang/is/settings.php +++ b/lang/is/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Viðhald', diff --git a/lang/it/errors.php b/lang/it/errors.php index 99700088b29..1d6c716361d 100644 --- a/lang/it/errors.php +++ b/lang/it/errors.php @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'Il token segreto fornito per il token API utilizzato non è corretto', 'api_user_no_api_permission' => 'Il proprietario del token API utilizzato non ha il permesso di effettuare chiamate API', 'api_user_token_expired' => 'Il token di autorizzazione utilizzato è scaduto', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Solo le richieste GET sono consentite quando si utilizza l\'API con autenticazione basata sui cookie', // Settings & Maintenance 'maintenance_test_email_failure' => 'Si è verificato un errore durante l\'invio di una e-mail di prova:', diff --git a/lang/it/settings.php b/lang/it/settings.php index 88c6a4c11bf..2b5819b2aa7 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Capitoli Prima', 'sort_rule_op_chapters_last' => 'Capitoli dopo', 'sorting_page_limits' => 'Limiti Visualizzazione Per Pagina', - 'sorting_page_limits_desc' => 'Imposta il numero di elementi da visualizzare per pagina nei vari elenchi all\'interno del sistema. In genere, un numero inferiore garantisce prestazioni migliori, mentre un numero più elevato evita la necessità di cliccare su più pagine. Si consiglia di utilizzare un multiplo pari di 3 (18, 24, 30, ecc...).', + 'sorting_page_limits_desc' => 'Imposta il numero di elementi da visualizzare per pagina nei vari elenchi del sistema. In genere, un numero inferiore garantisce prestazioni migliori, mentre un numero maggiore evita di dover sfogliare più pagine. Si consiglia di utilizzare un multiplo di 6.', // Maintenance settings 'maint' => 'Manutenzione', diff --git a/lang/ja/errors.php b/lang/ja/errors.php index 7ce8db11100..0c1c8d84ad5 100644 --- a/lang/ja/errors.php +++ b/lang/ja/errors.php @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => '利用されたAPIトークンに対して提供されたシークレットが正しくありません', 'api_user_no_api_permission' => '使用されているAPIトークンの所有者には、API呼び出しを行う権限がありません', 'api_user_token_expired' => '認証トークンが期限切れです。', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Cookie ベースの認証で API を使用する場合、GET リクエストのみが許可されます', // Settings & Maintenance 'maintenance_test_email_failure' => 'テストメール送信時にエラーが発生しました:', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index 53b14233e71..717a4c10ffc 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'チャプタを最初に', 'sort_rule_op_chapters_last' => 'チャプタを最後に', 'sorting_page_limits' => 'ページング表示制限', - 'sorting_page_limits_desc' => 'システム内の各種リストで1ページに表示するアイテム数を設定します。 通常、少ない数に設定するとパフォーマンスが向上し、多い数に設定するとページの移動操作が少なくなります。 3の倍数(18、24、30など)を使用することをお勧めします。', + '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 'maint' => 'メンテナンス', diff --git a/lang/ka/settings.php b/lang/ka/settings.php index c68605fe1f8..c4d1eb136eb 100644 --- a/lang/ka/settings.php +++ b/lang/ka/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Maintenance', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 0488bfe140e..97af673c6af 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => '챕터 우선 정렬', 'sort_rule_op_chapters_last' => '챕터 나중 정렬', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => '유지관리', diff --git a/lang/ku/settings.php b/lang/ku/settings.php index c68605fe1f8..c4d1eb136eb 100644 --- a/lang/ku/settings.php +++ b/lang/ku/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Maintenance', diff --git a/lang/lt/settings.php b/lang/lt/settings.php index bcc7c82bd70..f797e567ec1 100644 --- a/lang/lt/settings.php +++ b/lang/lt/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Priežiūra', diff --git a/lang/lv/settings.php b/lang/lv/settings.php index fb5cf13ddee..9dc6bf402bd 100644 --- a/lang/lv/settings.php +++ b/lang/lv/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Nodaļas pirmās', 'sort_rule_op_chapters_last' => 'Nodaļas pēdējās', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Apkope', diff --git a/lang/nb/settings.php b/lang/nb/settings.php index 61b1c33671c..5fcaaaca6c1 100644 --- a/lang/nb/settings.php +++ b/lang/nb/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Kapitler først', 'sort_rule_op_chapters_last' => 'Kapitler sist', 'sorting_page_limits' => 'Visningsgrenser for hver side', - 'sorting_page_limits_desc' => 'Angi hvor mange elementer som skal vises på hver side i ulike lister i systemet. Et lavere antall vil vanligvis gi bedre ytelse, mens et høyere antall reduserer behovet for å bla gjennom mange sider. Det er anbefalt å bruke en multiplikasjon av 3 som gir partall (18, 24, 30 osv.).', + '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 'maint' => 'Vedlikehold', diff --git a/lang/ne/settings.php b/lang/ne/settings.php index 37e59978e1f..dbc7d8e9fc9 100644 --- a/lang/ne/settings.php +++ b/lang/ne/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'पहिले अध्यायहरू', 'sort_rule_op_chapters_last' => 'अन्त्यमा अध्यायहरू', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'सम्भार', diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 63805f4983c..c8d071119ff 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Hoofdstukken Eerst', 'sort_rule_op_chapters_last' => 'Hoofdstukken Laatst', 'sorting_page_limits' => 'Weergavelimiet Per Pagina', - 'sorting_page_limits_desc' => 'Stel in hoeveel items er op een pagina worden laten zien in de verschillende lijstweergaves. Een lager aantal verbeterd de snelheid, een hoger aantal verminderd het doorklikken door pagina\'s. Een even veelvoud van 3 (18, 24, 30, etc...) wordt aanbevolen.', + '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 'maint' => 'Onderhoud', diff --git a/lang/nn/settings.php b/lang/nn/settings.php index 6d2259bd88d..e4b6e6af93d 100644 --- a/lang/nn/settings.php +++ b/lang/nn/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Vedlikehold', diff --git a/lang/pl/settings.php b/lang/pl/settings.php index 5954b005f09..775d4f25d2c 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Rozdziały na początku', 'sort_rule_op_chapters_last' => 'Rozdziały na końcu', 'sorting_page_limits' => 'Limity wyświetlania per strona', - 'sorting_page_limits_desc' => 'Ustaw ile elementów pokazywać per strona w różnych listach w systemie. Zazwyczaj mniejsza ilość będzie bardziej wydajna, podczas gdy większa ilość unika konieczności przeglądania wielu stron. Zaleca się stosowanie parzystej wielokrotności 3 (18, 24, 30 itp...).', + '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 'maint' => 'Konserwacja', diff --git a/lang/pt/settings.php b/lang/pt/settings.php index c2179e64027..a59335b7cee 100644 --- a/lang/pt/settings.php +++ b/lang/pt/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Manutenção', diff --git a/lang/pt_BR/errors.php b/lang/pt_BR/errors.php index 4d711076f46..3287154331b 100644 --- a/lang/pt_BR/errors.php +++ b/lang/pt_BR/errors.php @@ -110,7 +110,7 @@ 'import_zip_cant_read' => 'Não foi possível ler o arquivo ZIP.', 'import_zip_cant_decode_data' => 'Não foi possível encontrar e decodificar o conteúdo ZIP data.json.', 'import_zip_no_data' => 'Os dados do arquivo ZIP não têm o conteúdo esperado livro, capítulo ou página.', - 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', + 'import_zip_data_too_large' => 'O conteúdo ZIP data.json excede o tamanho máximo de upload configurado para a aplicação.', 'import_validation_failed' => 'Falhou na validação da importação do ZIP com erros:', 'import_zip_failed_notification' => 'Falhou ao importar arquivo ZIP.', 'import_perms_books' => 'Você não tem as permissões necessárias para criar livros.', @@ -126,7 +126,7 @@ 'api_incorrect_token_secret' => 'O segredo fornecido para o código de API usado está incorreto', 'api_user_no_api_permission' => 'O proprietário do código de API utilizado não tem permissão para fazer requisições de API', 'api_user_token_expired' => 'O código de autenticação expirou', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Somente solicitações GET são permitidas ao usar a API com autenticação baseada em cookies', // Settings & Maintenance 'maintenance_test_email_failure' => 'Erro encontrado ao enviar uma mensagem eletrônica de teste:', diff --git a/lang/pt_BR/notifications.php b/lang/pt_BR/notifications.php index 8c98467c81d..7f6c100b1d3 100644 --- a/lang/pt_BR/notifications.php +++ b/lang/pt_BR/notifications.php @@ -11,8 +11,8 @@ 'updated_page_subject' => 'Página atualizada: :pageName', 'updated_page_intro' => 'Uma página foi atualizada em :appName:', 'updated_page_debounce' => 'Para prevenir notificações em massa, por enquanto notificações não serão enviadas para você para próximas edições nessa página pelo mesmo editor.', - 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', - 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', + 'comment_mention_subject' => 'Você foi mencionado em um comentário na página: :pageName', + 'comment_mention_intro' => 'Você foi mencionado em um comentário sobre :appName:', 'detail_page_name' => 'Nome da Página:', 'detail_page_path' => 'Caminho da Página:', diff --git a/lang/pt_BR/preferences.php b/lang/pt_BR/preferences.php index d2b7fc540a0..a92d971c3c3 100644 --- a/lang/pt_BR/preferences.php +++ b/lang/pt_BR/preferences.php @@ -23,7 +23,7 @@ 'notifications_desc' => 'Controle as notificações por e-mail que você recebe quando uma determinada atividade é executada no sistema.', 'notifications_opt_own_page_changes' => 'Notificar quando houver alterações em páginas que eu possuo', 'notifications_opt_own_page_comments' => 'Notificar comentários nas páginas que eu possuo', - 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', + 'notifications_opt_comment_mentions' => 'Notificar quando eu for mencionado em um comentário', 'notifications_opt_comment_replies' => 'Notificar ao responder aos meus comentários', 'notifications_save' => 'Salvar Preferências', 'notifications_update_success' => 'Preferências de notificação foram atualizadas!', diff --git a/lang/pt_BR/settings.php b/lang/pt_BR/settings.php index 53947a36da6..97b434727f8 100644 --- a/lang/pt_BR/settings.php +++ b/lang/pt_BR/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Capítulos Primeiro', 'sort_rule_op_chapters_last' => 'Capítulos por Último', 'sorting_page_limits' => 'Limites de exibição por página', - 'sorting_page_limits_desc' => 'Defina quantos itens serão exibidos por página em diferentes listas do sistema. Normalmente, um número menor proporciona melhor desempenho, enquanto um número maior evita a necessidade de clicar em várias páginas. É recomendado o uso de um múltiplo par de 3 (18, 24, 30, etc.).', + 'sorting_page_limits_desc' => 'Defina quantos itens mostrar por página em várias listas no sistema. Normalmente, uma quantidade menor será mais eficiente, enquanto uma quantidade maior evita a necessidade de clicar em várias páginas. Recomenda-se usar um múltiplo de 6.', // Maintenance settings 'maint' => 'Manutenção', @@ -197,13 +197,13 @@ 'role_import_content' => 'Importar conteúdo', 'role_editor_change' => 'Alterar página de edição', 'role_notifications' => 'Receber e gerenciar notificações', - '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' => 'Essas permissões tecnicamente também fornecerão visibilidade e busca de usuários e perfis no sistema.', 'role_asset' => 'Permissões de Ativos', 'roles_system_warning' => 'Esteja ciente de que o acesso a qualquer uma das três permissões acima pode permitir que um usuário altere seus próprios privilégios ou privilégios de outros usuários no sistema. Apenas atribua perfis com essas permissões para usuários confiáveis.', 'role_asset_desc' => 'Essas permissões controlam o acesso padrão para os ativos dentro do sistema. Permissões em Livros, Capítulos e Páginas serão sobrescritas por essas permissões.', 'role_asset_admins' => 'Administradores recebem automaticamente acesso a todo o conteúdo, mas essas opções podem mostrar ou ocultar as opções da Interface de Usuário.', 'role_asset_image_view_note' => 'Isso está relacionado à visibilidade no gerenciador de imagens. O acesso real dos arquivos de imagem carregados dependerá da opção de armazenamento de imagem do sistema.', - 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', + 'role_asset_users_note' => 'Essas permissões tecnicamente também fornecerão visibilidade e busca de usuários do sistema.', 'role_all' => 'Todos', 'role_own' => 'Próprio', 'role_controlled_by_asset' => 'Controlado pelos ativos nos quais o upload foi realizado', diff --git a/lang/pt_BR/validation.php b/lang/pt_BR/validation.php index e30ebc3778e..4d8139c4b5c 100644 --- a/lang/pt_BR/validation.php +++ b/lang/pt_BR/validation.php @@ -106,7 +106,7 @@ 'uploaded' => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.', 'zip_file' => 'O :attribute precisa fazer referência a um arquivo do ZIP.', - 'zip_file_size' => 'The file :attribute must not exceed :size MB.', + 'zip_file_size' => 'O arquivo :attribute não deve exceder :size MB.', 'zip_file_mime' => 'O :attribute precisa fazer referência a um arquivo do tipo :validTypes, encontrado :foundType.', 'zip_model_expected' => 'Objeto de dados esperado, mas ":type" encontrado.', 'zip_unique' => 'O :attribute deve ser único para o tipo de objeto dentro do ZIP.', diff --git a/lang/ro/settings.php b/lang/ro/settings.php index 02052ef3cd6..d65a8e0714f 100644 --- a/lang/ro/settings.php +++ b/lang/ro/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Mentenanţă', diff --git a/lang/ru/entities.php b/lang/ru/entities.php index 50f061115d9..bf224c10a91 100644 --- a/lang/ru/entities.php +++ b/lang/ru/entities.php @@ -39,21 +39,21 @@ 'export_pdf' => 'PDF файл', 'export_text' => 'Текстовый файл', 'export_md' => 'Файл Markdown', - 'export_zip' => 'Portable ZIP', + 'export_zip' => 'Портативный ZIP', 'default_template' => 'Шаблон страницы по умолчанию', 'default_template_explain' => 'Назначить шаблон страницы, который будет использоваться в качестве содержимого по умолчанию для всех страниц, созданных в этом элементе. Имейте в виду, что это будет работать, только если создатель страницы имеет доступ к выбранной странице шаблона.', 'default_template_select' => 'Выберите страницу шаблона', 'import' => 'Импорт', - 'import_validate' => 'Validate Import', + 'import_validate' => 'Проверка импорта', 'import_desc' => 'Импортировать книги, главы и страницы с помощью ZIP-файла, экспортированного из этого или другого источника. Выберите ZIP-файл, чтобы продолжить. После загрузки и проверки файла вы сможете настроить и подтвердить импорт в следующем окне.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', + 'import_zip_select' => 'Выберите ZIP файл для загрузки', + 'import_zip_validation_errors' => 'Были обнаружены ошибки при проверке предоставленного ZIP файла:', + '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_run' => 'Run Import', + 'import_run' => 'Запустить импорт', 'import_size' => ':size Import ZIP Size', 'import_uploaded_at' => 'Uploaded :relativeTime', 'import_uploaded_by' => 'Uploaded by', @@ -61,7 +61,7 @@ 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', 'import_delete_confirm' => 'Are you sure you want to delete this import?', 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', + 'import_errors' => 'Ошибки импорта', 'import_errors_desc' => 'The follow errors occurred during the import attempt:', 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', @@ -252,7 +252,7 @@ '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_desc' => '(In Beta Testing)', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(В бета-тестировании)', 'pages_edit_set_changelog' => 'Задать список изменений', 'pages_edit_enter_changelog_desc' => 'Введите краткое описание внесенных изменений', 'pages_edit_enter_changelog' => 'Введите список изменений', @@ -397,11 +397,11 @@ 'comment' => 'Комментарий', 'comments' => 'Комментарии', 'comment_add' => 'Комментировать', - 'comment_none' => 'No comments to display', + 'comment_none' => 'Нет комментариев для отображения', 'comment_placeholder' => 'Оставить комментарий здесь', 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', - 'comment_archived_threads' => 'Archived Threads', + 'comment_archived_count' => ':count архивировано', + 'comment_archived_threads' => 'Архивированные темы', 'comment_save' => 'Сохранить комментарий', 'comment_new' => 'Новый комментарий', 'comment_created' => 'прокомментировал :createDiff', @@ -410,14 +410,14 @@ 'comment_deleted_success' => 'Комментарий удален', 'comment_created_success' => 'Комментарий добавлен', 'comment_updated_success' => 'Комментарий обновлен', - 'comment_archive_success' => 'Comment archived', - 'comment_unarchive_success' => 'Comment un-archived', - 'comment_view' => 'View comment', - 'comment_jump_to_thread' => 'Jump to thread', + 'comment_archive_success' => 'Комментарий заархивирован', + 'comment_unarchive_success' => 'Комментарий разархивирован', + 'comment_view' => 'Просмотреть комментарий', + 'comment_jump_to_thread' => 'Перейти к теме', 'comment_delete_confirm' => 'Удалить этот комментарий?', 'comment_in_reply_to' => 'В ответ на :commentId', - 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', + 'comment_reference' => 'Ссылка', + 'comment_reference_outdated' => '(Устаревшее)', 'comment_editor_explain' => 'Вот комментарии, которые были оставлены на этой странице. Комментарии могут быть добавлены и управляться при просмотре сохраненной страницы.', // Revision diff --git a/lang/ru/notifications.php b/lang/ru/notifications.php index 289de42b6c0..96e853723b7 100644 --- a/lang/ru/notifications.php +++ b/lang/ru/notifications.php @@ -11,7 +11,7 @@ 'updated_page_subject' => 'Обновлена страница: :pageName', 'updated_page_intro' => 'Страница была обновлена в :appName:', 'updated_page_debounce' => 'Чтобы предотвратить массовые уведомления, в течение некоторого времени вы не будете получать уведомления о дальнейших правках этой страницы этим же редактором.', - 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_subject' => 'Вы были упомянуты в комментарии на странице: :pageName', 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Имя страницы:', diff --git a/lang/ru/settings.php b/lang/ru/settings.php index 47839d52006..76a2eebbf1e 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -75,7 +75,7 @@ 'reg_confirm_restrict_domain_placeholder' => 'Без ограничений', // Sorting Settings - 'sorting' => 'Lists & Sorting', + 'sorting' => 'Списки и сортировка', 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Выберите правило сортировки по умолчанию для новых книг. Это не повлияет на существующие книги, и может быть изменено для каждой книги отдельно.', 'sorting_rules' => 'Правила сортировки', @@ -101,10 +101,10 @@ 'sort_rule_op_name_numeric' => 'По нумерации', 'sort_rule_op_created_date' => 'Created Date', 'sort_rule_op_updated_date' => 'Updated Date', - 'sort_rule_op_chapters_first' => 'Chapters First', + 'sort_rule_op_chapters_first' => 'Главы в начале', 'sort_rule_op_chapters_last' => 'Главы в конце', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Обслуживание', diff --git a/lang/sk/settings.php b/lang/sk/settings.php index 04855a7f96e..67671f6f82c 100644 --- a/lang/sk/settings.php +++ b/lang/sk/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Údržba', diff --git a/lang/sl/settings.php b/lang/sl/settings.php index 6eaed0a1702..947621389f4 100644 --- a/lang/sl/settings.php +++ b/lang/sl/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Vzdrževanje', diff --git a/lang/sq/settings.php b/lang/sq/settings.php index c68605fe1f8..c4d1eb136eb 100644 --- a/lang/sq/settings.php +++ b/lang/sq/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Maintenance', diff --git a/lang/sr/settings.php b/lang/sr/settings.php index d34ff3f3b7d..f6c86827e69 100644 --- a/lang/sr/settings.php +++ b/lang/sr/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Одржавање', diff --git a/lang/sv/entities.php b/lang/sv/entities.php index 08c44ff1fe1..680e0908aa7 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -397,7 +397,7 @@ 'comment' => 'Kommentar', 'comments' => 'Kommentarer', 'comment_add' => 'Lägg till kommentar', - 'comment_none' => 'No comments to display', + 'comment_none' => 'Inga kommentarer att visa', 'comment_placeholder' => 'Lämna en kommentar här', 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', 'comment_archived_count' => ':count Archived', diff --git a/lang/sv/preferences.php b/lang/sv/preferences.php index 492081e59e3..7ebf2681350 100644 --- a/lang/sv/preferences.php +++ b/lang/sv/preferences.php @@ -5,7 +5,7 @@ */ return [ - 'my_account' => 'My Account', + 'my_account' => 'Mitt Konto', 'shortcuts' => 'Genvägar', 'shortcuts_interface' => 'UI Shortcut Preferences', diff --git a/lang/sv/settings.php b/lang/sv/settings.php index 2e86241dae4..773c4bff35e 100644 --- a/lang/sv/settings.php +++ b/lang/sv/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Underhåll', diff --git a/lang/tk/settings.php b/lang/tk/settings.php index c68605fe1f8..c4d1eb136eb 100644 --- a/lang/tk/settings.php +++ b/lang/tk/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Maintenance', diff --git a/lang/tr/settings.php b/lang/tr/settings.php index 71d56000fc1..a33d3e0ac04 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Bakım', diff --git a/lang/uk/settings.php b/lang/uk/settings.php index 55966c01c57..afeb2c48928 100644 --- a/lang/uk/settings.php +++ b/lang/uk/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Спочатку розділи', 'sort_rule_op_chapters_last' => 'Розділи останні', 'sorting_page_limits' => 'Обмеження відображення сторінок', - 'sorting_page_limits_desc' => 'Кількість елементів для відображення в різних списках в системі. Зазвичай менша кількість буде більш продуктивною, в той час як більша кількість уникає необхідність натискання на кілька сторінок. Рекомендується використовувати парне кратне 3 (18, 24, 30 тощо).', + '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 'maint' => 'Обслуговування', diff --git a/lang/uz/settings.php b/lang/uz/settings.php index ad191143f40..259aee71a3f 100644 --- a/lang/uz/settings.php +++ b/lang/uz/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Xizmat', diff --git a/lang/vi/settings.php b/lang/vi/settings.php index 69eadddd36f..f5f2377c81b 100644 --- a/lang/vi/settings.php +++ b/lang/vi/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chương trước', 'sort_rule_op_chapters_last' => 'Chương sau', '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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => 'Bảo trì', diff --git a/lang/zh_CN/editor.php b/lang/zh_CN/editor.php index 6dbca56e313..4bac4cf0d6b 100644 --- a/lang/zh_CN/editor.php +++ b/lang/zh_CN/editor.php @@ -48,7 +48,7 @@ 'superscript' => '上标', 'subscript' => '下标', 'text_color' => '文本颜色', - 'highlight_color' => 'Highlight color', + 'highlight_color' => '高亮颜色', 'custom_color' => '自定义颜色', 'remove_color' => '移除颜色', 'background_color' => '背景色', diff --git a/lang/zh_CN/entities.php b/lang/zh_CN/entities.php index 826a8ec1e65..c4ec0414dab 100644 --- a/lang/zh_CN/entities.php +++ b/lang/zh_CN/entities.php @@ -63,10 +63,10 @@ 'import_delete_desc' => '这将删除上传的ZIP文件,不能撤消。', 'import_errors' => '导入错误', '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', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => '导航页面', + 'breadcrumb_siblings_for_chapter' => '导航章节', + 'breadcrumb_siblings_for_book' => '导航书籍', + 'breadcrumb_siblings_for_bookshelf' => '导航书架', // Permissions and restrictions 'permissions' => '权限', @@ -399,7 +399,7 @@ 'comment_add' => '添加评论', 'comment_none' => '没有要显示的评论', 'comment_placeholder' => '在这里评论', - 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', + 'comment_thread_count' => ':count 条', 'comment_archived_count' => ':count 条评论已存档', 'comment_archived_threads' => '已存档的贴子', 'comment_save' => '保存评论', diff --git a/lang/zh_CN/errors.php b/lang/zh_CN/errors.php index 74814c6b0c0..98b26407233 100644 --- a/lang/zh_CN/errors.php +++ b/lang/zh_CN/errors.php @@ -109,7 +109,7 @@ 'import_zip_cant_read' => '无法读取 ZIP 文件。', 'import_zip_cant_decode_data' => '无法找到并解码 ZIP data.json 内容。', 'import_zip_no_data' => 'ZIP 文件数据没有预期的书籍、章节或页面内容。', - 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', + 'import_zip_data_too_large' => '超出最大上传大小。', 'import_validation_failed' => '导入 ZIP 验证失败,出现错误:', 'import_zip_failed_notification' => 'ZIP 文件导入失败。', 'import_perms_books' => '您缺少创建书籍所需的权限。', @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => '给已给出的API所提供的密钥不正确', 'api_user_no_api_permission' => '使用过的 API 令牌的所有者没有进行API 调用的权限', 'api_user_token_expired' => '所使用的身份令牌已过期', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => '使用基于 Cookie 的身份验证 API 时,仅允许 GET 请求。', // Settings & Maintenance 'maintenance_test_email_failure' => '发送测试电子邮件时出现错误:', diff --git a/lang/zh_CN/notifications.php b/lang/zh_CN/notifications.php index e4eebf5cc56..55fa6824a5a 100644 --- a/lang/zh_CN/notifications.php +++ b/lang/zh_CN/notifications.php @@ -11,8 +11,8 @@ 'updated_page_subject' => '页面更新::pageName', 'updated_page_intro' => ':appName: 中的一个页面已被更新', 'updated_page_debounce' => '为了防止出现大量通知,一段时间内您不会收到同一编辑者再次编辑本页面的通知。', - 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', - 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', + 'comment_mention_subject' => '在页面中被提及::pageName', + 'comment_mention_intro' => '在 :appName 中被提及:', 'detail_page_name' => '页面名称:', 'detail_page_path' => '页面路径:', diff --git a/lang/zh_CN/preferences.php b/lang/zh_CN/preferences.php index f89448dd366..0a1f165bbac 100644 --- a/lang/zh_CN/preferences.php +++ b/lang/zh_CN/preferences.php @@ -23,7 +23,7 @@ '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/zh_CN/settings.php b/lang/zh_CN/settings.php index 3469752bfce..e53e67aba32 100644 --- a/lang/zh_CN/settings.php +++ b/lang/zh_CN/settings.php @@ -75,8 +75,8 @@ 'reg_confirm_restrict_domain_placeholder' => '尚未设置限制', // Sorting Settings - 'sorting' => 'Lists & Sorting', - 'sorting_book_default' => 'Default Book Sort Rule', + 'sorting' => '列表和排序', + 'sorting_book_default' => '默认排序规则', 'sorting_book_default_desc' => '选择要应用于新书的默认排序规则。这不会影响现有书,并且可以每本书覆盖。', 'sorting_rules' => '排序规则', 'sorting_rules_desc' => '这些是预定义的排序操作,可应用于系统中的内容。', @@ -103,8 +103,8 @@ '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_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 an even multiple of 3 (18, 24, 30, etc...) is recommended.', + '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 'maint' => '维护', @@ -197,13 +197,13 @@ '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' => '管理员可自动获得对所有内容的访问权限,但这些选项可能会显示或隐藏UI选项。', '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' => '由其所在的资源来控制', diff --git a/lang/zh_CN/validation.php b/lang/zh_CN/validation.php index 748c8f56771..955381ccbab 100644 --- a/lang/zh_CN/validation.php +++ b/lang/zh_CN/validation.php @@ -106,7 +106,7 @@ 'uploaded' => '无法上传文件。 服务器可能不接受此大小的文件。', 'zip_file' => ':attribute 需要引用 ZIP 内的文件。', - 'zip_file_size' => 'The file :attribute must not exceed :size MB.', + 'zip_file_size' => ':attribute 不能超过 :size MB 。', 'zip_file_mime' => ':attribute 需要引用类型为 :validTypes 的文件,找到 :foundType 。', 'zip_model_expected' => '预期的数据对象,但找到了 ":type" 。', 'zip_unique' => '对于 ZIP 中的对象类型来说,:attribute 必须是唯一的。', diff --git a/lang/zh_TW/settings.php b/lang/zh_TW/settings.php index 0d5d760dd29..65778f77ca3 100644 --- a/lang/zh_TW/settings.php +++ b/lang/zh_TW/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => '第一章', 'sort_rule_op_chapters_last' => '最後一章', 'sorting_page_limits' => '每頁顯示限制', - 'sorting_page_limits_desc' => '設定系統內各類清單每頁顯示的項目數量。通常較低的數量能提升效能表現,而較高的數量則可避免使用者需點擊翻閱多頁。建議採用 3 的整數倍數(如 18、24、30 等)。', + '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 'maint' => '維護', From 362859ac23761631949089de61df338ed002ca97 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Mar 2026 13:14:54 +0000 Subject: [PATCH 167/398] Updated translator & dependency attribution before release v26.03 --- .github/translators.txt | 3 +++ dev/licensing/js-library-licenses.txt | 19 ------------------- dev/licensing/php-library-licenses.txt | 2 +- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/.github/translators.txt b/.github/translators.txt index 14c51bd0b32..fcbb9675dda 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -530,3 +530,6 @@ Shadluk Avan (quldosh) :: Uzbek Marci (MartonPoto) :: Hungarian Michał Sadurski (wheeskeey) :: Polish JanDziaslo :: Polish +Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian +Ilgiz Zigangirov (inov8) :: Russian +Max Israelsson (Blezie) :: Swedish diff --git a/dev/licensing/js-library-licenses.txt b/dev/licensing/js-library-licenses.txt index d7ad4ecc8f2..1c23c093762 100644 --- a/dev/licensing/js-library-licenses.txt +++ b/dev/licensing/js-library-licenses.txt @@ -3321,20 +3321,6 @@ Copyright: Copyright 2022 Romain Menke, Antonio Laguna <*******@******.**> Source: git+https://github.com/csstools/postcss-plugins.git Link: https://github.com/csstools/postcss-plugins/tree/main/packages/css-tokenizer#readme ----------- -@emnapi/core -License: MIT -License File: node_modules/@emnapi/core/LICENSE -Copyright: Copyright (c) 2021-present Toyobayashi -Source: git+https://github.com/toyobayashi/emnapi.git -Link: https://github.com/toyobayashi/emnapi#readme ------------ -@emnapi/runtime -License: MIT -License File: node_modules/@emnapi/runtime/LICENSE -Copyright: Copyright (c) 2021-present Toyobayashi -Source: git+https://github.com/toyobayashi/emnapi.git -Link: https://github.com/toyobayashi/emnapi#readme ------------ @esbuild/linux-x64 License: MIT Source: git+https://github.com/evanw/esbuild.git @@ -3784,11 +3770,6 @@ Copyright: Copyright (c) Microsoft Corporation. Source: https://github.com/tsconfig/bases.git Link: https://github.com/tsconfig/bases.git ----------- -@tybys/wasm-util -License: MIT -Source: https://github.com/toyobayashi/wasm-util.git -Link: https://github.com/toyobayashi/wasm-util.git ------------ @types/babel__core License: MIT License File: node_modules/@types/babel__core/LICENSE diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt index 6ae0b86b936..348fce2ff98 100644 --- a/dev/licensing/php-library-licenses.txt +++ b/dev/licensing/php-library-licenses.txt @@ -8,7 +8,7 @@ aws/aws-sdk-php License: Apache-2.0 License File: vendor/aws/aws-sdk-php/LICENSE Source: https://github.com/aws/aws-sdk-php.git -Link: http://aws.amazon.com/sdkforphp +Link: https://aws.amazon.com/sdk-for-php ----------- bacon/bacon-qr-code License: BSD-2-Clause From 4f18fea08691bc0cfb2beab0e4495c16f1c8bdce Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Mar 2026 13:17:48 +0000 Subject: [PATCH 168/398] Deps: Updated PHP deps pre v26.03 release --- composer.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/composer.lock b/composer.lock index d8ea0066265..030cd1a7397 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.373.0", + "version": "3.373.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "fb74a2dca7ae2363e929c5cea33a4a4db0d22690" + "reference": "483fba51c28b3a0c0647bf5100e0edca82090b18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/fb74a2dca7ae2363e929c5cea33a4a4db0d22690", - "reference": "fb74a2dca7ae2363e929c5cea33a4a4db0d22690", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/483fba51c28b3a0c0647bf5100e0edca82090b18", + "reference": "483fba51c28b3a0c0647bf5100e0edca82090b18", "shasum": "" }, "require": { @@ -92,12 +92,12 @@ "aws/aws-php-sns-message-validator": "~1.0", "behat/behat": "~3.0", "composer/composer": "^2.7.8", - "dms/phpunit-arraysubset-asserts": "^0.4.0", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", "doctrine/cache": "~1.4", "ext-dom": "*", "ext-openssl": "*", "ext-sockets": "*", - "phpunit/phpunit": "^9.6", + "phpunit/phpunit": "^10.0", "psr/cache": "^2.0 || ^3.0", "psr/simple-cache": "^2.0 || ^3.0", "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", @@ -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.373.0" + "source": "https://github.com/aws/aws-sdk-php/tree/3.373.2" }, - "time": "2026-03-11T18:33:36+00:00" + "time": "2026-03-13T18:08:30+00:00" }, { "name": "bacon/bacon-qr-code", @@ -4941,16 +4941,16 @@ }, { "name": "robrichards/xmlseclibs", - "version": "3.1.4", + "version": "3.1.5", "source": { "type": "git", "url": "https://github.com/robrichards/xmlseclibs.git", - "reference": "bc87389224c6de95802b505e5265b0ec2c5bcdbd" + "reference": "03062be78178cbb5e8f605cd255dc32a14981f92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/bc87389224c6de95802b505e5265b0ec2c5bcdbd", - "reference": "bc87389224c6de95802b505e5265b0ec2c5bcdbd", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/03062be78178cbb5e8f605cd255dc32a14981f92", + "reference": "03062be78178cbb5e8f605cd255dc32a14981f92", "shasum": "" }, "require": { @@ -4977,9 +4977,9 @@ ], "support": { "issues": "https://github.com/robrichards/xmlseclibs/issues", - "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.4" + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.5" }, - "time": "2025-12-08T11:57:53+00:00" + "time": "2026-03-13T10:31:56+00:00" }, { "name": "sabberworm/php-css-parser", From a9ffd3e0c77019916bba21830d8514cca723ce17 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Mar 2026 18:28:44 +0000 Subject: [PATCH 169/398] Responses: Added extra sanitization for download names From testing, don't think this could exploited directly, as the response would error instead of allowing control characters, but this adds an extra layer of sanitization, and switches to encoded disposition filenames for better UTF8 support. --- app/Http/DownloadResponseFactory.php | 7 +++++-- tests/Api/ExportsApiTest.php | 30 ++++++++++++++-------------- tests/Exports/HtmlExportTest.php | 6 +++--- tests/Exports/MarkdownExportTest.php | 2 +- tests/Exports/PdfExportTest.php | 6 +++--- tests/Exports/TextExportTest.php | 6 +++--- tests/Uploads/AttachmentTest.php | 19 ++++++++++++++++-- 7 files changed, 47 insertions(+), 29 deletions(-) diff --git a/app/Http/DownloadResponseFactory.php b/app/Http/DownloadResponseFactory.php index 8384484ad62..1b6256c00ef 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -102,12 +102,15 @@ public function streamedFileInline(string $filePath, ?string $fileName = null): protected function getHeaders(string $fileName, int $fileSize, string $mime = 'application/octet-stream'): array { $disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline'; - $downloadName = str_replace('"', '', $fileName); + + $downloadName = str_replace(['"', '/', '\\', '$'], '', $fileName); + $downloadName = preg_replace('/[\x00-\x1F\x7F]/', '', $downloadName); + $encodedDownloadName = rawurlencode($downloadName); return [ 'Content-Type' => $mime, 'Content-Length' => $fileSize, - 'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"", + 'Content-Disposition' => "{$disposition}; filename*=UTF-8''{$encodedDownloadName}", 'X-Content-Type-Options' => 'nosniff', ]; } diff --git a/tests/Api/ExportsApiTest.php b/tests/Api/ExportsApiTest.php index e1ac698d0a4..7951e04d272 100644 --- a/tests/Api/ExportsApiTest.php +++ b/tests/Api/ExportsApiTest.php @@ -19,7 +19,7 @@ public function test_book_html_endpoint() $resp = $this->get("/api/books/{$book->id}/export/html"); $resp->assertStatus(200); $resp->assertSee($book->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.html'); } public function test_book_plain_text_endpoint() @@ -30,7 +30,7 @@ public function test_book_plain_text_endpoint() $resp = $this->get("/api/books/{$book->id}/export/plaintext"); $resp->assertStatus(200); $resp->assertSee($book->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.txt'); } public function test_book_pdf_endpoint() @@ -40,7 +40,7 @@ public function test_book_pdf_endpoint() $resp = $this->get("/api/books/{$book->id}/export/pdf"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.pdf'); } public function test_book_markdown_endpoint() @@ -50,7 +50,7 @@ public function test_book_markdown_endpoint() $resp = $this->get("/api/books/{$book->id}/export/markdown"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.md'); $resp->assertSee('# ' . $book->name); $resp->assertSee('# ' . $book->pages()->first()->name); $resp->assertSee('# ' . $book->chapters()->first()->name); @@ -63,7 +63,7 @@ public function test_book_zip_endpoint() $resp = $this->get("/api/books/{$book->id}/export/zip"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.zip"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.zip'); $zip = ZipTestHelper::extractFromZipResponse($resp); $this->assertArrayHasKey('book', $zip->data); @@ -77,7 +77,7 @@ public function test_chapter_html_endpoint() $resp = $this->get("/api/chapters/{$chapter->id}/export/html"); $resp->assertStatus(200); $resp->assertSee($chapter->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.html'); } public function test_chapter_plain_text_endpoint() @@ -88,7 +88,7 @@ public function test_chapter_plain_text_endpoint() $resp = $this->get("/api/chapters/{$chapter->id}/export/plaintext"); $resp->assertStatus(200); $resp->assertSee($chapter->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.txt'); } public function test_chapter_pdf_endpoint() @@ -98,7 +98,7 @@ public function test_chapter_pdf_endpoint() $resp = $this->get("/api/chapters/{$chapter->id}/export/pdf"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.pdf'); } public function test_chapter_markdown_endpoint() @@ -108,7 +108,7 @@ public function test_chapter_markdown_endpoint() $resp = $this->get("/api/chapters/{$chapter->id}/export/markdown"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.md'); $resp->assertSee('# ' . $chapter->name); $resp->assertSee('# ' . $chapter->pages()->first()->name); } @@ -120,7 +120,7 @@ public function test_chapter_zip_endpoint() $resp = $this->get("/api/chapters/{$chapter->id}/export/zip"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.zip"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.zip'); $zip = ZipTestHelper::extractFromZipResponse($resp); $this->assertArrayHasKey('chapter', $zip->data); @@ -134,7 +134,7 @@ public function test_page_html_endpoint() $resp = $this->get("/api/pages/{$page->id}/export/html"); $resp->assertStatus(200); $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.html'); } public function test_page_plain_text_endpoint() @@ -145,7 +145,7 @@ public function test_page_plain_text_endpoint() $resp = $this->get("/api/pages/{$page->id}/export/plaintext"); $resp->assertStatus(200); $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.txt'); } public function test_page_pdf_endpoint() @@ -155,7 +155,7 @@ public function test_page_pdf_endpoint() $resp = $this->get("/api/pages/{$page->id}/export/pdf"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.pdf'); } public function test_page_markdown_endpoint() @@ -166,7 +166,7 @@ public function test_page_markdown_endpoint() $resp = $this->get("/api/pages/{$page->id}/export/markdown"); $resp->assertStatus(200); $resp->assertSee('# ' . $page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.md'); } public function test_page_zip_endpoint() @@ -176,7 +176,7 @@ public function test_page_zip_endpoint() $resp = $this->get("/api/pages/{$page->id}/export/zip"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.zip"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.zip'); $zip = ZipTestHelper::extractFromZipResponse($resp); $this->assertArrayHasKey('page', $zip->data); diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php index e039fb2cc2b..f23352e0eb9 100644 --- a/tests/Exports/HtmlExportTest.php +++ b/tests/Exports/HtmlExportTest.php @@ -18,7 +18,7 @@ public function test_page_html_export() $resp = $this->get($page->getUrl('/export/html')); $resp->assertStatus(200); $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.html'); } public function test_book_html_export() @@ -31,7 +31,7 @@ public function test_book_html_export() $resp->assertStatus(200); $resp->assertSee($book->name); $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.html'); } public function test_book_html_export_shows_html_descriptions() @@ -58,7 +58,7 @@ public function test_chapter_html_export() $resp->assertStatus(200); $resp->assertSee($chapter->name); $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.html'); } public function test_chapter_html_export_shows_html_descriptions() diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php index 09928ced29e..ac1e283f34f 100644 --- a/tests/Exports/MarkdownExportTest.php +++ b/tests/Exports/MarkdownExportTest.php @@ -14,7 +14,7 @@ public function test_page_markdown_export() $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); $resp->assertStatus(200); $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.md'); } public function test_page_markdown_export_uses_existing_markdown_if_apparent() diff --git a/tests/Exports/PdfExportTest.php b/tests/Exports/PdfExportTest.php index e4de87d0d2f..f311f8457db 100644 --- a/tests/Exports/PdfExportTest.php +++ b/tests/Exports/PdfExportTest.php @@ -17,7 +17,7 @@ public function test_page_pdf_export() $resp = $this->get($page->getUrl('/export/pdf')); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.pdf'); } public function test_book_pdf_export() @@ -28,7 +28,7 @@ public function test_book_pdf_export() $resp = $this->get($book->getUrl('/export/pdf')); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.pdf'); } public function test_chapter_pdf_export() @@ -38,7 +38,7 @@ public function test_chapter_pdf_export() $resp = $this->get($chapter->getUrl('/export/pdf')); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.pdf'); } diff --git a/tests/Exports/TextExportTest.php b/tests/Exports/TextExportTest.php index c593a6585cb..4b2d6288775 100644 --- a/tests/Exports/TextExportTest.php +++ b/tests/Exports/TextExportTest.php @@ -14,7 +14,7 @@ public function test_page_text_export() $resp = $this->get($page->getUrl('/export/plaintext')); $resp->assertStatus(200); $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.txt'); } public function test_book_text_export() @@ -35,7 +35,7 @@ public function test_book_text_export() $resp->assertSee($directPage->name); $resp->assertSee('My awesome page'); $resp->assertSee('My little nested page'); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.txt'); } public function test_book_text_export_format() @@ -68,7 +68,7 @@ public function test_chapter_text_export() $resp->assertSee($chapter->name); $resp->assertSee($page->name); $resp->assertSee('This is content within the page!'); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.txt'); } public function test_chapter_text_export_format() diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index b443ca9f117..945fc258d77 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -324,7 +324,7 @@ public function test_file_access_with_open_query_param_provides_inline_response_ $attachmentGet = $this->get($attachment->getUrl(true)); // http-foundation/Response does some 'fixing' of responses to add charsets to text responses. $attachmentGet->assertHeader('Content-Type', 'text/plain; charset=utf-8'); - $attachmentGet->assertHeader('Content-Disposition', 'inline; filename="upload_test_file.txt"'); + $attachmentGet->assertHeader('Content-Disposition', 'inline; filename*=UTF-8\'\'upload_test_file.txt'); $attachmentGet->assertHeader('X-Content-Type-Options', 'nosniff'); $this->files->deleteAllAttachmentFiles(); @@ -340,7 +340,22 @@ public function test_html_file_access_with_open_forces_plain_content_type() $attachmentGet = $this->get($attachment->getUrl(true)); // http-foundation/Response does some 'fixing' of responses to add charsets to text responses. $attachmentGet->assertHeader('Content-Type', 'text/plain; charset=utf-8'); - $attachmentGet->assertHeader('Content-Disposition', 'inline; filename="test_file.html"'); + $attachmentGet->assertHeader('Content-Disposition', 'inline; filename*=UTF-8\'\'test_file.html'); + + $this->files->deleteAllAttachmentFiles(); + } + + public function test_file_access_name_in_content_disposition_header_is_sanitized() + { + $page = $this->entities->page(); + $this->asAdmin(); + + $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'test_file.html', '

testing

', 'text/html'); + $attachment->name = "my\\_/super\n_fu\$n_\tfile"; + $attachment->save(); + + $attachmentGet = $this->get($attachment->getUrl(true)); + $attachmentGet->assertHeader('Content-Disposition', 'inline; filename*=UTF-8\'\'my_super_fun_file.html'); $this->files->deleteAllAttachmentFiles(); } From 0120b475eb18c7d4e1851a937cafd12724fabb31 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Mar 2026 10:59:11 +0000 Subject: [PATCH 170/398] Deps: Updated PHP deps pre v26.03.1 --- composer.lock | 58 +++++++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/composer.lock b/composer.lock index 030cd1a7397..e1e56ff9db6 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.373.2", + "version": "3.373.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "483fba51c28b3a0c0647bf5100e0edca82090b18" + "reference": "d23edc4cf9cd81cb98b5beb9c1fb3737f535b1e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/483fba51c28b3a0c0647bf5100e0edca82090b18", - "reference": "483fba51c28b3a0c0647bf5100e0edca82090b18", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d23edc4cf9cd81cb98b5beb9c1fb3737f535b1e5", + "reference": "d23edc4cf9cd81cb98b5beb9c1fb3737f535b1e5", "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.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.373.3" }, - "time": "2026-03-13T18:08:30+00:00" + "time": "2026-03-16T18:15:27+00:00" }, { "name": "bacon/bacon-qr-code", - "version": "v3.0.3", + "version": "v3.0.4", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563" + "reference": "3feed0e212b8412cc5d2612706744789b0615824" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563", - "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824", + "reference": "3feed0e212b8412cc5d2612706744789b0615824", "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.3" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4" }, - "time": "2025-11-19T17:15:36+00:00" + "time": "2026-03-16T01:01:30+00:00" }, { "name": "brick/math", @@ -2943,20 +2943,20 @@ }, { "name": "league/uri", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.8", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -3029,7 +3029,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.8.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -3037,20 +3037,20 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -3113,7 +3113,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -3121,7 +3121,7 @@ "type": "github" } ], - "time": "2026-01-15T06:54:53+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "masterminds/html5", @@ -9169,11 +9169,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.1.41", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a2eae8f20856b3afe74bf1f9726ce8c11438e300", + "reference": "a2eae8f20856b3afe74bf1f9726ce8c11438e300", "shasum": "" }, "require": { @@ -9218,7 +9218,7 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-03-16T18:24:10+00:00" }, { "name": "phpunit/php-code-coverage", From 04dd9f8e19301fde58218864ae51eef6b678f27a Mon Sep 17 00:00:00 2001 From: Rodrigo Primo Date: Tue, 17 Mar 2026 17:21:01 -0300 Subject: [PATCH 171/398] Update PHP_CodeSniffer repository link --- dev/docs/development.md | 2 +- readme.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/docs/development.md b/dev/docs/development.md index 418e9ead672..2c73a0256c5 100644 --- a/dev/docs/development.md +++ b/dev/docs/development.md @@ -37,7 +37,7 @@ We use tools to manage code standards and formatting within the project. If subm ### PHP -PHP code standards are managed by [using PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer). +PHP code standards are managed by [using PHP_CodeSniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer). Static analysis is in place using [PHPStan](https://phpstan.org/) & [Larastan](https://github.com/nunomaduro/larastan). The below commands can be used to utilise these tools: diff --git a/readme.md b/readme.md index 194f81e4a05..00eb135046e 100644 --- a/readme.md +++ b/readme.md @@ -178,7 +178,7 @@ Note: This is not an exhaustive list of all libraries and projects that would be * [phpseclib](https://github.com/phpseclib/phpseclib) - _[MIT](https://github.com/phpseclib/phpseclib/blob/master/LICENSE)_ * [Clockwork](https://github.com/itsgoingd/clockwork) - _[MIT](https://github.com/itsgoingd/clockwork/blob/master/LICENSE)_ * [PHPStan](https://phpstan.org/) & [Larastan](https://github.com/nunomaduro/larastan) - _[MIT](https://github.com/phpstan/phpstan/blob/master/LICENSE) and [MIT](https://github.com/nunomaduro/larastan/blob/master/LICENSE.md)_ -* [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) - _[BSD 3-Clause](https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt)_ +* [PHP_CodeSniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer) - _[BSD 3-Clause](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt)_ * [JakeArchibald/IDB-Keyval](https://github.com/jakearchibald/idb-keyval) - _[Apache-2.0](https://github.com/jakearchibald/idb-keyval/blob/main/LICENCE)_ * [HTML Purifier](https://github.com/ezyang/htmlpurifier) and [htmlpurifier-html5](https://github.com/xemlock/htmlpurifier-html5) - _[LGPL-2.1](https://github.com/ezyang/htmlpurifier/blob/master/LICENSE) and [MIT](https://github.com/xemlock/htmlpurifier-html5/blob/master/LICENSE)_ From 5763d26b175769675f78ce5955601313ac772a15 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 19 Mar 2026 21:29:30 +0000 Subject: [PATCH 172/398] Updated registration to use validated input instead of all --- app/Access/Controllers/RegisterController.php | 3 +-- app/Access/RegistrationService.php | 6 ++--- tests/Auth/RegistrationTest.php | 24 +++++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/Access/Controllers/RegisterController.php b/app/Access/Controllers/RegisterController.php index e9812aa5d06..f0261fba80d 100644 --- a/app/Access/Controllers/RegisterController.php +++ b/app/Access/Controllers/RegisterController.php @@ -48,8 +48,7 @@ public function getRegister() public function postRegister(Request $request) { $this->registrationService->ensureRegistrationAllowed(); - $this->validator($request->all())->validate(); - $userData = $request->all(); + $userData = $this->validator($request->all())->validate(); try { $user = $this->registrationService->registerUser($userData); diff --git a/app/Access/RegistrationService.php b/app/Access/RegistrationService.php index 68992fbc65c..e47479e7991 100644 --- a/app/Access/RegistrationService.php +++ b/app/Access/RegistrationService.php @@ -83,7 +83,7 @@ public function registerUser(array $userData, ?SocialAccount $socialAccount = nu // Email restriction $this->ensureEmailDomainAllowed($userEmail); - // Ensure user does not already exist + // Ensure the user does not already exist $alreadyUser = !is_null($this->userRepo->getByEmail($userEmail)); if ($alreadyUser) { throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login'); @@ -99,7 +99,7 @@ public function registerUser(array $userData, ?SocialAccount $socialAccount = nu $newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed); $newUser->attachDefaultRole(); - // Assign social account if given + // Assign a social account if given if ($socialAccount) { $newUser->socialAccounts()->save($socialAccount); } @@ -107,7 +107,7 @@ public function registerUser(array $userData, ?SocialAccount $socialAccount = nu Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser); Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser); - // Start email confirmation flow if required + // Start the email confirmation flow if required if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) { $newUser->save(); diff --git a/tests/Auth/RegistrationTest.php b/tests/Auth/RegistrationTest.php index 2666fa3b4c7..e0d7c262682 100644 --- a/tests/Auth/RegistrationTest.php +++ b/tests/Auth/RegistrationTest.php @@ -188,6 +188,30 @@ public function test_registration_validation() $resp->assertSee('The password must be at least 8 characters.'); } + public function test_registration_input_filtered_to_validated_input() + { + $this->setSettings(['registration-enabled' => 'true']); + $roleIds = Role::all()->pluck('id')->toArray(); + + $resp = $this->post('/register', [ + 'name' => 'Barry', + 'email' => 'barry@example.com', + 'password' => 'superpassword', + 'password_confirmation' => 'superpassword', + 'external_auth_id' => 'ext5691284', + 'roles' => $roleIds, + ]); + + $resp->assertRedirect('/'); + + /** @var User $user */ + $user = auth()->user(); + $this->assertNotNull($user); + $this->assertFalse($user->isGuest()); + $this->assertEmpty($user->external_auth_id); + $this->assertEquals(0, $user->roles()->count()); + } + public function test_registration_simple_honeypot_active() { $this->setSettings(['registration-enabled' => 'true']); From a44756168dcbce0813aaefa073f9dc7e59420636 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 22 Mar 2026 17:18:04 +0000 Subject: [PATCH 173/398] WYSIWYG: Aligned double click to set label for details functionality Aligned the behaviour across the WYSIWYG editors, and also for nested details blocks (which wasn't working in the TinyMCE implementation). Closes #6059 --- .../js/wysiwyg-tinymce/plugins-details.js | 10 ++++++++-- .../js/wysiwyg/lexical/core/LexicalEditor.ts | 19 +++++++++++++++++++ .../lexical/rich-text/LexicalDetailsNode.ts | 11 +++++++++++ .../js/wysiwyg/ui/defaults/buttons/objects.ts | 2 +- resources/js/wysiwyg/ui/framework/manager.ts | 7 +++++-- 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/resources/js/wysiwyg-tinymce/plugins-details.js b/resources/js/wysiwyg-tinymce/plugins-details.js index c4a6d927d2b..58b0ac9d869 100644 --- a/resources/js/wysiwyg-tinymce/plugins-details.js +++ b/resources/js/wysiwyg-tinymce/plugins-details.js @@ -19,6 +19,8 @@ function setSummary(editor, summaryContent) { } summary.textContent = summaryContent; }); + + editor.selection.select(details); } /** @@ -202,8 +204,12 @@ function register(editor) { }); editor.on('dblclick', event => { - if (!getSelectedDetailsBlock(editor) || event.target.closest('doc-root')) return; - showDetailLabelEditWindow(editor); + const domElClass = event?.target?.ownerDocument?.defaultView?.HTMLDetailsElement; + if (domElClass && event.target instanceof domElClass && getSelectedDetailsBlock(editor)) { + showDetailLabelEditWindow(editor); + event.preventDefault(); + event.stopPropagation(); + } }); editor.ui.registry.addButton('toggledetails', { diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts index 364f6c6b7c3..46660c9b74a 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts @@ -45,6 +45,7 @@ import {LineBreakNode} from './nodes/LexicalLineBreakNode'; import {ParagraphNode} from './nodes/LexicalParagraphNode'; import {RootNode} from './nodes/LexicalRootNode'; import {TabNode} from './nodes/LexicalTabNode'; +import {EditorUiContext} from "../../ui/framework/core"; export type Spread = Omit & T1; @@ -621,6 +622,8 @@ export class LexicalEditor { _editable: boolean; /** @internal */ _blockCursorElement: null | HTMLDivElement; + /** @internal */ + _context: null | EditorUiContext; /** @internal */ constructor( @@ -682,6 +685,7 @@ export class LexicalEditor { this._headless = parentEditor !== null && parentEditor._headless; this._window = null; this._blockCursorElement = null; + this._context = null; } /** @@ -1285,6 +1289,21 @@ export class LexicalEditor { triggerListeners('editable', this, true, editable); } } + + /** + * Set the UI context that this editor is intended to be part of. + */ + setUiContext(context: EditorUiContext) { + this._context = context; + } + + /** + * Get the UI context that this editor is considered to be part of. + */ + getUiContext(): EditorUiContext|null { + return this._context; + } + /** * Returns a JSON-serializable javascript object NOT a JSON string. * You still must call JSON.stringify (or something else) to turn the diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index cdf32fdcbb1..70ae7f0f9f6 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -9,6 +9,7 @@ import { } from 'lexical'; import {extractDirectionFromElement} from "lexical/nodes/common"; +import {$showDetailsForm} from "../../ui/defaults/forms/objects"; export type SerializedDetailsNode = Spread<{ id: string; @@ -90,6 +91,16 @@ export class DetailsNode extends ElementNode { }); }); + summary.addEventListener('dblclick', event => { + event.preventDefault(); + const uiContext = _editor.getUiContext(); + if (uiContext) { + _editor.read(() => { + $showDetailsForm(this, uiContext); + }); + } + }); + el.append(summary); return el; diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 00dc9500ec8..bd6e41da918 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -221,7 +221,7 @@ export const detailsEditLabel: EditorButtonDefinition = { if ($isDetailsNode(details)) { $showDetailsForm(details, context); } - }) + }); }, isActive(selection: BaseSelection | null): boolean { return false; diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 78d0cc9a27a..3b4d5b495a8 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -29,7 +29,7 @@ export class EditorUIManager { setContext(context: EditorUiContext) { this.context = context; this.setupEventListeners(); - this.setupEditor(context.editor); + this.setupEditor(context.editor, context); } getContext(): EditorUiContext { @@ -256,7 +256,10 @@ export class EditorUIManager { } } - protected setupEditor(editor: LexicalEditor) { + protected setupEditor(editor: LexicalEditor, context: EditorUiContext) { + // Pass the context to the editor + editor.setUiContext(context); + // Register our DOM decorate listener with the editor const domDecorateListener: DecoratorListener = (decorators: Record) => { editor.getEditorState().read(() => { From 5ebc1fe3b0a071b93148f16703c6d96c4af25ccf Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 22 Mar 2026 17:22:13 +0000 Subject: [PATCH 174/398] Deps: Updated PHP package versions pre v26.03.2 release --- composer.lock | 80 +++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/composer.lock b/composer.lock index e1e56ff9db6..d6069720d29 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.373.3", + "version": "3.373.7", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "d23edc4cf9cd81cb98b5beb9c1fb3737f535b1e5" + "reference": "4402bd10f913e66b7271f44466be8d5ba6c9146e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d23edc4cf9cd81cb98b5beb9c1fb3737f535b1e5", - "reference": "d23edc4cf9cd81cb98b5beb9c1fb3737f535b1e5", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/4402bd10f913e66b7271f44466be8d5ba6c9146e", + "reference": "4402bd10f913e66b7271f44466be8d5ba6c9146e", "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.373.3" + "source": "https://github.com/aws/aws-sdk-php/tree/3.373.7" }, - "time": "2026-03-16T18:15:27+00:00" + "time": "2026-03-20T18:14:19+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1801,16 +1801,16 @@ }, { "name": "laravel/framework", - "version": "v12.54.1", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "325497463e7599cd14224c422c6e5dd2fe832868" + "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/325497463e7599cd14224c422c6e5dd2fe832868", - "reference": "325497463e7599cd14224c422c6e5dd2fe832868", + "url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33", + "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33", "shasum": "" }, "require": { @@ -1926,7 +1926,7 @@ "orchestra/testbench-core": "^10.9.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.1.41", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0|^1.0", @@ -2019,20 +2019,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-10T20:25:56+00:00" + "time": "2026-03-18T14:28:59+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.14", + "version": "v0.3.15", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176" + "reference": "4bb8107ec97651fd3f17f897d6489dbc4d8fb999" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", - "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", + "url": "https://api.github.com/repos/laravel/prompts/zipball/4bb8107ec97651fd3f17f897d6489dbc4d8fb999", + "reference": "4bb8107ec97651fd3f17f897d6489dbc4d8fb999", "shasum": "" }, "require": { @@ -2076,9 +2076,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.14" + "source": "https://github.com/laravel/prompts/tree/v0.3.15" }, - "time": "2026-03-01T09:02:38+00:00" + "time": "2026-03-17T13:45:17+00:00" }, { "name": "laravel/serializable-closure", @@ -2281,16 +2281,16 @@ }, { "name": "league/commonmark", - "version": "2.8.1", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "84b1ca48347efdbe775426f108622a42735a6579" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579", - "reference": "84b1ca48347efdbe775426f108622a42735a6579", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -2384,7 +2384,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T21:37:03+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -4027,16 +4027,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.49", + "version": "3.0.50", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", "shasum": "" }, "require": { @@ -4117,7 +4117,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" }, "funding": [ { @@ -4133,7 +4133,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:17:28+00:00" + "time": "2026-03-19T02:57:58+00:00" }, { "name": "pragmarx/google2fa", @@ -5154,22 +5154,22 @@ }, { "name": "socialiteproviders/manager", - "version": "v4.8.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Manager.git", - "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4" + "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4", - "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/35372dc62787e61e91cfec73f45fd5d5ae0f8891", + "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891", "shasum": "" }, "require": { - "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/support": "^11.0 || ^12.0 || ^13.0", "laravel/socialite": "^5.5", - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "mockery/mockery": "^1.2", @@ -5224,7 +5224,7 @@ "issues": "https://github.com/socialiteproviders/manager/issues", "source": "https://github.com/socialiteproviders/manager" }, - "time": "2025-02-24T19:33:30+00:00" + "time": "2026-03-18T22:13:24+00:00" }, { "name": "socialiteproviders/microsoft-azure", @@ -9169,11 +9169,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.41", + "version": "2.1.42", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a2eae8f20856b3afe74bf1f9726ce8c11438e300", - "reference": "a2eae8f20856b3afe74bf1f9726ce8c11438e300", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", "shasum": "" }, "require": { @@ -9218,7 +9218,7 @@ "type": "github" } ], - "time": "2026-03-16T18:24:10+00:00" + "time": "2026-03-17T14:58:32+00:00" }, { "name": "phpunit/php-code-coverage", From fd6867e577f271289ac6ba1490af2aef784996e8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Mar 2026 10:05:51 +0000 Subject: [PATCH 175/398] Updated translations with latest Crowdin changes (#6064) --- lang/cs/settings.php | 2 +- lang/de/errors.php | 4 ++-- lang/de/notifications.php | 4 ++-- lang/de/preferences.php | 2 +- lang/de_informal/errors.php | 4 ++-- lang/de_informal/notifications.php | 4 ++-- lang/de_informal/preferences.php | 2 +- lang/fr/settings.php | 2 +- lang/ja/settings.php | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lang/cs/settings.php b/lang/cs/settings.php index ef25f1a2035..a8c4036e87e 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Kapitoly jako první', 'sort_rule_op_chapters_last' => 'Kapitoly jako poslední', 'sorting_page_limits' => 'Počet zobrazených položek na stránce', - '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' => 'Nastavte, kolik položek se má zobrazit na stránce v různých seznamech na webu. Obvykle bude nižší počet výkonnější, zatímco vyšší počet eliminuje nutnost proklikávat se několika stránkami. Doporučuje se použít násobek čísla 6.', // Maintenance settings 'maint' => 'Údržba', diff --git a/lang/de/errors.php b/lang/de/errors.php index 75c1cf3944e..ee5071f8578 100644 --- a/lang/de/errors.php +++ b/lang/de/errors.php @@ -109,7 +109,7 @@ 'import_zip_cant_read' => 'ZIP-Datei konnte nicht gelesen werden.', 'import_zip_cant_decode_data' => 'ZIP data.json konnte nicht gefunden und dekodiert werden.', 'import_zip_no_data' => 'ZIP-Datei Daten haben kein erwartetes Buch, Kapitel oder Seiteninhalt.', - 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', + 'import_zip_data_too_large' => 'Der Inhalt der ZIP data.json überschreitet die maximale Dateigröße der Anwendung.', 'import_validation_failed' => 'ZIP Import konnte mit Fehlern nicht validiert werden:', 'import_zip_failed_notification' => 'Importieren der ZIP-Datei fehlgeschlagen.', 'import_perms_books' => 'Ihnen fehlt die erforderliche Berechtigung, um Bücher zu erstellen.', @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'Das Kennwort für das angegebene API-Token ist falsch', 'api_user_no_api_permission' => 'Der Besitzer des verwendeten API-Tokens hat keine Berechtigung für API-Aufrufe', 'api_user_token_expired' => 'Das verwendete Autorisierungstoken ist abgelaufen', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Nur GET Anfragen sind erlaubt, wenn die API mit Cookie-basierter Authentifizierung verwendet wird', // Settings & Maintenance 'maintenance_test_email_failure' => 'Fehler beim Versenden einer Test E-Mail:', diff --git a/lang/de/notifications.php b/lang/de/notifications.php index 71b71d1b662..cd8f4c87418 100644 --- a/lang/de/notifications.php +++ b/lang/de/notifications.php @@ -11,8 +11,8 @@ '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, werden Sie für eine gewisse Zeit keine Benachrichtigungen für weitere Bearbeitungen dieser Seite durch denselben Bearbeiter erhalten.', - 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', - 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', + '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', 'detail_page_name' => 'Name der Seite:', 'detail_page_path' => 'Seitenpfad:', diff --git a/lang/de/preferences.php b/lang/de/preferences.php index 1f74f3d3eb0..858cf35a38c 100644 --- a/lang/de/preferences.php +++ b/lang/de/preferences.php @@ -23,7 +23,7 @@ 'notifications_desc' => 'Legen Sie fest, welche E-Mail-Benachrichtigungen Sie erhalten, wenn bestimmte Aktivitäten im System durchgeführt werden.', 'notifications_opt_own_page_changes' => 'Benachrichtigung bei Änderungen an eigenen Seiten', 'notifications_opt_own_page_comments' => 'Benachrichtigung bei Kommentaren an eigenen Seiten', - 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', + 'notifications_opt_comment_mentions' => 'Bei Erwähnung mich benachrichtigen', 'notifications_opt_comment_replies' => 'Bei Antworten auf meine Kommentare benachrichtigen', 'notifications_save' => 'Einstellungen speichern', 'notifications_update_success' => 'Benachrichtigungseinstellungen wurden aktualisiert!', diff --git a/lang/de_informal/errors.php b/lang/de_informal/errors.php index a3be19a028d..f43b4e5acf6 100644 --- a/lang/de_informal/errors.php +++ b/lang/de_informal/errors.php @@ -109,7 +109,7 @@ 'import_zip_cant_read' => 'ZIP-Datei konnte nicht gelesen werden.', 'import_zip_cant_decode_data' => 'Konnte Inhalt der data.json im ZIP nicht finden und dekodieren.', 'import_zip_no_data' => 'ZIP-Datei hat kein erwartetes Buch, Kapitel oder Seiteninhalt.', - 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', + 'import_zip_data_too_large' => 'Der Inhalt der ZIP data.json überschreitet die maximale Dateigröße der Anwendung.', 'import_validation_failed' => 'ZIP Import konnte aufgrund folgender Fehler nicht validiert werden:', 'import_zip_failed_notification' => 'Importieren der ZIP-Datei fehlgeschlagen.', 'import_perms_books' => 'Dir fehlt die erforderliche Berechtigung, um Bücher zu erstellen.', @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'Das für den API-Token angegebene geheime Token ist falsch', 'api_user_no_api_permission' => 'Der Besitzer des verwendeten API-Token hat keine Berechtigung für API-Aufrufe', 'api_user_token_expired' => 'Das verwendete Autorisierungs-Token ist abgelaufen', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Nur GET Anfragen sind erlaubt, wenn die API mit Cookie-basierter Authentifizierung verwendet wird', // Settings & Maintenance 'maintenance_test_email_failure' => 'Fehler beim Senden einer Test E-Mail:', diff --git a/lang/de_informal/notifications.php b/lang/de_informal/notifications.php index 99c270ec1be..4df17078320 100644 --- a/lang/de_informal/notifications.php +++ b/lang/de_informal/notifications.php @@ -11,8 +11,8 @@ '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' => 'You have been mentioned in a comment on page: :pageName', - 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', + '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', 'detail_page_name' => 'Seitenname:', 'detail_page_path' => 'Seitenpfad:', diff --git a/lang/de_informal/preferences.php b/lang/de_informal/preferences.php index bfb57b2f492..443f70b0a87 100644 --- a/lang/de_informal/preferences.php +++ b/lang/de_informal/preferences.php @@ -23,7 +23,7 @@ 'notifications_desc' => 'Lege fest, welche E-Mail-Benachrichtigungen du erhältst, wenn bestimmte Aktivitäten im System durchgeführt werden.', 'notifications_opt_own_page_changes' => 'Benachrichtigung bei Änderungen an eigenen Seiten', 'notifications_opt_own_page_comments' => 'Benachrichtigung bei Kommentaren an eigenen Seiten', - 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', + 'notifications_opt_comment_mentions' => 'Bei Erwähnung mich benachrichtigen', 'notifications_opt_comment_replies' => 'Bei Antworten auf meine Kommentare benachrichtigen', 'notifications_save' => 'Einstellungen speichern', 'notifications_update_success' => 'Benachrichtigungseinstellungen wurden aktualisiert!', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index 7b0987a488c..8c6c57f33aa 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Chapitres en premier', 'sort_rule_op_chapters_last' => 'Chapitres en dernier', 'sorting_page_limits' => 'Limite d\'affichage par page', - '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' => 'Définissez le nombre d’éléments à afficher par page dans les différentes listes du système. En général, un nombre plus faible offre de meilleures performances, tandis qu’un nombre plus élevé réduit le besoin de naviguer entre plusieurs pages. Il est recommandé d’utiliser un multiple de 6.', // Maintenance settings 'maint' => 'Maintenance', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index 717a4c10ffc..378e3a7748e 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'チャプタを最初に', 'sort_rule_op_chapters_last' => 'チャプタを最後に', '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.', + 'sorting_page_limits_desc' => 'システム内の各種リストで1ページに表示するアイテム数を設定します。 通常、少ない数に設定するとパフォーマンスが向上し、多い数に設定するとページの移動操作が少なくなります。6 の倍数に設定することをお勧めします。', // Maintenance settings 'maint' => 'メンテナンス', From 1763ac550b3dbc7339cbdbb9791253bcddbaf67a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Mar 2026 10:08:38 +0000 Subject: [PATCH 176/398] Meta: Updated translators pre v26.03.2 release --- .github/translators.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/translators.txt b/.github/translators.txt index fcbb9675dda..97ab6c3fc6f 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -533,3 +533,4 @@ JanDziaslo :: Polish Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian Ilgiz Zigangirov (inov8) :: Russian Max Israelsson (Blezie) :: Swedish +Skiddybison5924 (chris-devel0per) :: German 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 177/398] 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 8dd7be375e8..22f15d1b82a 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 e42fda893c62286d2948b0f18f4dd2fd846de098 Mon Sep 17 00:00:00 2001 From: Zhey Date: Thu, 26 Mar 2026 12:00:08 +0100 Subject: [PATCH 178/398] Add CSP controls for image and CSS sources --- .env.example.complete | 12 +++++++ app/Config/app.php | 10 ++++++ app/Util/CspService.php | 65 ++++++++++++++++++++++++++++++++++++ dev/docs/development.md | 42 +++++++++++++++++++++++ readme.md | 1 + tests/SecurityHeaderTest.php | 36 ++++++++++++++++++++ 6 files changed, 166 insertions(+) diff --git a/.env.example.complete b/.env.example.complete index ebebaf9e3e8..6a7f9db6529 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -395,6 +395,18 @@ ALLOWED_IFRAME_HOSTS=null # Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured. ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com" +# A list of sources/hostnames that can be loaded as CSS styles within BookStack. +# Space separated if multiple. BookStack host domain is auto-inferred. +# Defaults to a permissive set if not provided. +# Example: ALLOWED_CSS_SOURCES="https://fonts.googleapis.com" +ALLOWED_CSS_SOURCES=null + +# A list of sources/hostnames that can be loaded as image content within BookStack. +# Space separated if multiple. BookStack host domain is auto-inferred. +# Defaults to a permissive set if not provided. +# Example: ALLOWED_IMAGE_SOURCES="https://images.example.com data:" +ALLOWED_IMAGE_SOURCES=null + # A list of the sources/hostnames that can be reached by application SSR calls. # This is used wherever users can provide URLs/hosts in-platform, like for webhooks. # Host-specific functionality (usually controlled via other options) like auth diff --git a/app/Config/app.php b/app/Config/app.php index a476fdfea1c..5536e9abdd4 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -72,6 +72,16 @@ // Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured. 'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'), + // A list of sources/hostnames that can be loaded as CSS styles within BookStack. + // Space separated if multiple. BookStack host domain is auto-inferred. + // If not set, a permissive default set is used to reduce potential breakage. + 'css_sources' => env('ALLOWED_CSS_SOURCES', null), + + // A list of sources/hostnames that can be loaded as image content within BookStack. + // Space separated if multiple. BookStack host domain is auto-inferred. + // If not set, a permissive default set is used to reduce potential breakage. + 'image_sources' => env('ALLOWED_IMAGE_SOURCES', null), + // A list of the sources/hostnames that can be reached by application SSR calls. // This is used wherever users can provide URLs/hosts in-platform, like for webhooks. // Host-specific functionality (usually controlled via other options) like auth diff --git a/app/Util/CspService.php b/app/Util/CspService.php index 466acb49148..a0e1faadf95 100644 --- a/app/Util/CspService.php +++ b/app/Util/CspService.php @@ -30,6 +30,8 @@ public function getCspHeader(): string $this->getFrameAncestors(), $this->getFrameSrc(), $this->getScriptSrc(), + $this->getStyleSrc(), + $this->getImgSrc(), $this->getObjectSrc(), $this->getBaseUri(), ]; @@ -45,6 +47,8 @@ public function getCspMetaTagValue(): string $headers = [ $this->getFrameSrc(), $this->getScriptSrc(), + $this->getStyleSrc(), + $this->getImgSrc(), $this->getObjectSrc(), $this->getBaseUri(), ]; @@ -115,6 +119,22 @@ protected function getObjectSrc(): string return "object-src 'self'"; } + /** + * Creates CSP 'style-src' rule to restrict where styles can be loaded from. + */ + protected function getStyleSrc(): string + { + return 'style-src ' . implode(' ', $this->getAllowedStyleSources()); + } + + /** + * Creates CSP 'img-src' rule to restrict where images can be loaded from. + */ + protected function getImgSrc(): string + { + return 'img-src ' . implode(' ', $this->getAllowedImageSources()); + } + /** * Creates CSP 'base-uri' rule to restrict what base tags can be set on * the page to prevent manipulation of relative links. @@ -144,6 +164,51 @@ protected function getAllowedIframeSources(): array return array_filter($sources); } + /** + * Get allowed style sources for the style-src directive. + */ + protected function getAllowedStyleSources(): array + { + $configured = config('app.css_sources'); + + if (is_string($configured)) { + $sources = array_filter(explode(' ', $configured)); + array_unshift($sources, "'self'"); + + return array_values(array_unique($sources)); + } + + return [ + "'self'", + "'unsafe-inline'", + 'http:', + 'https:', + ]; + } + + /** + * Get allowed image sources for the img-src directive. + */ + protected function getAllowedImageSources(): array + { + $configured = config('app.image_sources'); + + if (is_string($configured)) { + $sources = array_filter(explode(' ', $configured)); + array_unshift($sources, "'self'"); + + return array_values(array_unique($sources)); + } + + return [ + "'self'", + 'data:', + 'blob:', + 'http:', + 'https:', + ]; + } + /** * Extract the host name of the configured drawio URL for use in CSP. * Returns empty string if not in use. diff --git a/dev/docs/development.md b/dev/docs/development.md index 2c73a0256c5..16f168f9cda 100644 --- a/dev/docs/development.md +++ b/dev/docs/development.md @@ -31,6 +31,48 @@ BookStack has a large suite of PHP tests to cover application functionality. We For details about setting-up, running and writing tests please see the [php-testing.md document](php-testing.md). +## Content Security Policy Controls + +BookStack enforces a Content Security Policy (CSP) response header to reduce risk from injected content and untrusted embeds. + +For backward compatibility, image and CSS controls are intentionally permissive by default, but can be tightened via environment options. + +### Related Environment Options + +These values are defined in `.env.example.complete`: + +- `ALLOWED_CSS_SOURCES` + - Controls allowed `style-src` sources. + - Defaults to a permissive fallback if unset. +- `ALLOWED_IMAGE_SOURCES` + - Controls allowed `img-src` sources. + - Defaults to a permissive fallback if unset. + +Values should be space-separated source expressions. + +### Example Configurations + +Allow Google Fonts CSS and local styles only: + +```bash +ALLOWED_CSS_SOURCES="https://fonts.googleapis.com" +``` + +Allow local images, embedded data images, and a dedicated image CDN: + +```bash +ALLOWED_IMAGE_SOURCES="data: https://images.example.com" +``` + +### Tightening Guidance + +When hardening a deployment: + +1. Start with defaults to avoid unexpected breakage. +2. Set explicit `ALLOWED_CSS_SOURCES` and `ALLOWED_IMAGE_SOURCES` values for the domains you actually use. +3. Test key workflows (editor, page display, theme assets, external embeds) and browser console CSP warnings. +4. Remove unnecessary protocols and hosts over time. + ## Code Standards We use tools to manage code standards and formatting within the project. If submitting a PR, formatting as per our project standards would help for clarity but don't worry too much about using/understanding these tools as we can always address issues at a later stage when they're picked up by our automated tools. diff --git a/readme.md b/readme.md index 00eb135046e..672cff55a41 100644 --- a/readme.md +++ b/readme.md @@ -102,6 +102,7 @@ Big thanks to these companies for supporting the project. ## 🛠️ Development & Testing Please see our [development docs](dev/docs/development.md) for full details regarding work on the BookStack source code. +For details on Content Security Policy controls (including image and CSS source options), see the **Content Security Policy Controls** section in the [development docs](dev/docs/development.md). If you're just looking to customize or extend your own BookStack instance, take a look at our [Hacking BookStack documentation page](https://www.bookstackapp.com/docs/admin/hacking-bookstack/) for details on various options to achieve this without altering the BookStack source code. diff --git a/tests/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php index 3f4b7d193ce..126a85f238c 100644 --- a/tests/SecurityHeaderTest.php +++ b/tests/SecurityHeaderTest.php @@ -151,6 +151,42 @@ public function test_frame_src_csp_header_drawio_host_includes_port_if_existing( $this->assertEquals('frame-src \'self\' https://example.com https://diagrams.example.com:8080', $scriptHeader); } + public function test_style_src_csp_header_set_to_permissive_defaults_when_not_configured() + { + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'style-src'); + + $this->assertEquals("style-src 'self' 'unsafe-inline' http: https:", $header); + } + + public function test_style_src_csp_header_can_be_overridden_by_config() + { + config()->set('app.css_sources', 'https://fonts.example.com'); + + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'style-src'); + + $this->assertEquals("style-src 'self' https://fonts.example.com", $header); + } + + public function test_img_src_csp_header_set_to_permissive_defaults_when_not_configured() + { + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'img-src'); + + $this->assertEquals("img-src 'self' data: blob: http: https:", $header); + } + + public function test_img_src_csp_header_can_be_overridden_by_config() + { + config()->set('app.image_sources', 'https://images.example.com data:'); + + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'img-src'); + + $this->assertEquals("img-src 'self' https://images.example.com data:", $header); + } + public function test_cache_control_headers_are_set_on_responses() { // Public access From c7d3775bb999bda0d134de900b28f2ebeb4cec67 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 5 Apr 2026 00:05:10 +0100 Subject: [PATCH 179/398] 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 ab7d917729c..3faa76657b6 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -9,6 +9,7 @@ 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 @@ public function safeHtml(): string 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 30d0ffa2be4..d739f4aabbf 100644 --- a/app/Activity/Notifications/Messages/CommentCreationNotification.php +++ b/app/Activity/Notifications/Messages/CommentCreationNotification.php @@ -24,7 +24,7 @@ public function toMail(User $notifiable): MailMessage $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 de9e719633d..4c8ee5bab8b 100644 --- a/app/Activity/Notifications/Messages/CommentMentionNotification.php +++ b/app/Activity/Notifications/Messages/CommentMentionNotification.php @@ -24,7 +24,7 @@ public function toMail(User $notifiable): MailMessage $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 717e9c9f82a..44baeaccfdc 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -16,6 +16,7 @@ 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 @@ protected function updateDescription(Entity $entity, array $input): void } 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 8d89a86cff4..b86fbbe8bdd 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -16,6 +16,7 @@ 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 @@ protected function setUniqueId(DOMNode $element, array &$idMap): array 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 00000000000..79da9e3d862 --- /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 00000000000..e522e486360 --- /dev/null +++ b/tests/Util/HtmlToPlainTextTest.php @@ -0,0 +1,63 @@ +This is a test

+
    +
  • Item 1
  • +
  • Item 2
  • +
+

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 180/398] 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 c5973eace29..dec8aa23d8f 100644 --- a/app/Exports/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -11,6 +11,7 @@ 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 @@ protected function containHtml(string $htmlContent): string /** * 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 @@ public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fr */ 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 4b2d6288775..26298c185da 100644 --- a/tests/Exports/TextExportTest.php +++ b/tests/Exports/TextExportTest.php @@ -52,7 +52,7 @@ public function test_book_text_export_format() $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 @@ public function test_chapter_text_export_format() $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 181/398] Deps: Updated PHP package versions --- composer.lock | 341 +++++++++++++++++++++++++------------------------- 1 file changed, 171 insertions(+), 170 deletions(-) diff --git a/composer.lock b/composer.lock index d6069720d29..a70aa9a0ff9 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 182/398] 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 f135f06a90b..628e1319e21 100644 --- a/lang/da/editor.php +++ b/lang/da/editor.php @@ -36,7 +36,7 @@ '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 23cc94626c5..ecda8a8cf8a 100644 --- a/lang/da/entities.php +++ b/lang/da/entities.php @@ -63,10 +63,10 @@ '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 eeb96f59b8d..62a275547ff 100644 --- a/lang/da/errors.php +++ b/lang/da/errors.php @@ -125,7 +125,7 @@ '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 cd869c62fe0..1edf10d0ec9 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -104,7 +104,7 @@ '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 94c327b7e06..52f9b7acad1 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -23,175 +23,175 @@ '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 @@ '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 @@ '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 @@ '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 @@ '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 @@ '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 e57ac52c457..64af973a46f 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -17,143 +17,142 @@ '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 @@ // 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 @@ '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 @@ // 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 a3e6e5f369c..16502051cee 100644 --- a/lang/de_informal/entities.php +++ b/lang/de_informal/entities.php @@ -63,10 +63,10 @@ '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 fa175187b80..97ad607d6bc 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -76,8 +76,8 @@ '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 @@ '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 @@ '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 b5c4ae8070c..a274586c180 100644 --- a/lang/ko/editor.php +++ b/lang/ko/editor.php @@ -178,5 +178,5 @@ 'shortcuts_intro' => '편집기에서 사용할 수 있는 바로 가기는 다음과 같습니다:', 'windows_linux' => '(윈도우/리눅스)', 'mac' => '(맥)', - 'description' => '상세정보', + 'description' => '설명', ]; diff --git a/lang/ko/errors.php b/lang/ko/errors.php index 9fa56ffcdc3..ca7c54573a2 100644 --- a/lang/ko/errors.php +++ b/lang/ko/errors.php @@ -71,8 +71,8 @@ '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 a0d83ac1e73..3ffc6680b3c 100644 --- a/lang/pt/common.php +++ b/lang/pt/common.php @@ -30,8 +30,8 @@ '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 b60f813b51b..e3069909c67 100644 --- a/lang/pt/editor.php +++ b/lang/pt/editor.php @@ -13,7 +13,7 @@ 'cancel' => 'Cancelar', 'save' => 'Guardar', 'close' => 'Fechar', - 'apply' => 'Apply', + 'apply' => 'Aplicar', 'undo' => 'Anular', 'redo' => 'Refazer', 'left' => 'Esquerda', @@ -166,7 +166,7 @@ '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 91fe6d90cd1..e882c79d001 100644 --- a/lang/pt/entities.php +++ b/lang/pt/entities.php @@ -23,7 +23,7 @@ '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 @@ '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 @@ '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 @@ '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 183/398] 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 97ab6c3fc6f..037887bcc36 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 184/398] 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 13f9a8a9819..379f1ebfaa7 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 53bfceb053e..f7b5596de46 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 b6508f1e9e4..d239426ec2c 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 45c275863b3..80810fff170 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 dacaefea279..55b61674cdb 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 185/398] 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 b545d161f13..06a8723c5c1 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 1a5156d3e7a..e950c5504b7 100644 --- a/app/Access/EmailConfirmationService.php +++ b/app/Access/EmailConfirmationService.php @@ -5,6 +5,7 @@ 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 c81e955722c..0c32b538f28 100644 --- a/app/Access/LoginService.php +++ b/app/Access/LoginService.php @@ -71,7 +71,7 @@ public function reattemptLoginFor(User $user): void } $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 dd3e04618f7..b0f08e82684 100644 --- a/app/Access/Mfa/MfaValue.php +++ b/app/Access/Mfa/MfaValue.php @@ -48,17 +48,16 @@ public static function upsertWithValue(User $user, string $method, string $value } /** - * 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 3dab3e44275..3edba12b36f 100644 --- a/app/Access/Oidc/OidcJwtSigningKey.php +++ b/app/Access/Oidc/OidcJwtSigningKey.php @@ -9,10 +9,7 @@ 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 @@ public function __construct($jwkOrKeyPath) /** * @throws OidcInvalidKeyException */ - protected function loadFromPath(string $path) + protected function loadFromPath(string $path): void { try { $key = PublicKeyLoader::load( @@ -58,7 +53,7 @@ protected function loadFromPath(string $path) /** * @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 @@ protected function loadFromJwkArray(array $jwk) 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 06c04d81eb7..9d7eeead1a9 100644 --- a/app/Access/Oidc/OidcJwtWithClaims.php +++ b/app/Access/Oidc/OidcJwtWithClaims.php @@ -102,12 +102,12 @@ public function replaceClaims(array $claims): void 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 7a422a58de2..b1736f97d31 100644 --- a/app/Access/Oidc/OidcUserDetails.php +++ b/app/Access/Oidc/OidcUserDetails.php @@ -39,7 +39,7 @@ public function populate( ): 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 106a7a22906..5572d210401 100644 --- a/app/Access/Saml2Service.php +++ b/app/Access/Saml2Service.php @@ -266,7 +266,7 @@ protected function getExternalId(array $samlAttributes, string $defaultValue) /** * 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 @@ public function processLoginCallback(string $samlID, array $samlAttributes): Use ]); } - 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 c3c20587db3..bdcfb45c865 100644 --- a/app/Access/SocialAuthService.php +++ b/app/Access/SocialAuthService.php @@ -117,14 +117,14 @@ public function handleLoginCallback(string $socialDriver, SocialUser $socialUser } // 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 8a6c26ffbed..38da2c552a5 100644 --- a/app/Activity/Notifications/NotificationManager.php +++ b/app/Activity/Notifications/NotificationManager.php @@ -15,14 +15,14 @@ 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 @@ public function handle(Activity $activity, string|Loggable $detail, User $user): */ 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 eb8f5508c70..a59cb8198e2 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -17,7 +17,14 @@ class ApiDocsGenerator { + /** + * @var array + */ protected array $reflectionClasses = []; + + /** + * @var array + */ protected array $controllerClasses = []; /** @@ -107,7 +114,6 @@ protected function loadDetailsFromControllers(Collection $routes): Collection */ 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 @@ protected function parseDescriptionFromDocBlockComment(string $comment): string $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 3c94d96ee60..23073bfc2fd 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -74,18 +74,21 @@ public function withTags(): self /** * 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 9f4537b296b..f1a3f0dc883 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 @@ public function user() } /** - * 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 @@ protected function validateTokenHeaderValue(string $authToken): void 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 @@ protected function validateToken(?ApiToken $token, string $secret): void /** * {@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 @@ public function validate(array $credentials = []) /** * "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 c438d078326..f00df83831c 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 c5e2d504e75..1207621debc 100644 --- a/app/Console/Commands/CopyShelfPermissionsCommand.php +++ b/app/Console/Commands/CopyShelfPermissionsCommand.php @@ -32,6 +32,7 @@ public function handle(PermissionsUpdater $permissionsUpdater, BookshelfQueries { $shelfSlug = $this->option('slug'); $cascadeAll = $this->option('all'); + $noInteraction = boolval($this->option('no-interaction')); $shelves = null; if (!$cascadeAll && !$shelfSlug) { @@ -41,14 +42,16 @@ public function handle(PermissionsUpdater $permissionsUpdater, BookshelfQueries } 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 1909dbd5631..10f04695a5e 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -17,7 +17,7 @@ * * @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 47e13462691..27cfccaa836 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -479,6 +479,7 @@ public static function instanceFromType(string $type): self '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 a1d3fc7b40d..d3a392da6fa 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -23,7 +23,7 @@ * @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 bc590785d93..375bf1d2bc1 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -60,7 +60,7 @@ public function getNewDraftPage(Entity $parent): Page $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 8d89a86cff4..6a764990079 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -359,7 +359,7 @@ protected function getContentCacheKey(string $html): string { $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 fa9ae753c51..f3165b603e5 100644 --- a/app/Entities/Tools/PermissionsUpdater.php +++ b/app/Entities/Tools/PermissionsUpdater.php @@ -47,7 +47,7 @@ public function updateFromApiRequestData(Entity $entity, array $data): void { 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 c5973eace29..6bf0a05add9 100644 --- a/app/Exports/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -208,7 +208,7 @@ protected function containHtml(string $htmlContent): string 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 @@ protected function containHtml(string $htmlContent): string 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 382e4073eec..9fa7dec3afe 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -82,10 +82,8 @@ public function run(Import $import, ?Entity $parent = null): Entity $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 @@ protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader '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 @@ protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader 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 @@ protected function importChapter(ZipExportChapter $exportChapter, Book $parent, $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 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z '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 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| 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 a6560e3f289..9bb069ab7f1 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -68,10 +68,6 @@ public function parseReferences(string $content, callable $handler): string $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 1a0f5932e6f..796505795e5 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -62,7 +62,7 @@ protected function showPermissionError(string $redirectLocation = '/'): never */ 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 56b22ad1604..94f18916d4a 100644 --- a/app/Permissions/JointPermissionBuilder.php +++ b/app/Permissions/JointPermissionBuilder.php @@ -61,8 +61,7 @@ public function rebuildForEntity(Entity $entity): void 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 83af2d043d8..cfd068386ef 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -121,13 +121,11 @@ protected function addOptionsFromString(string $searchString): void 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 @@ public function getAdditionalOptionsString(): string $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 b4f93d47b11..0862aaa8877 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -125,9 +125,8 @@ public function sortUsingMap(BookSortMap $sortMap): array */ 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 a16b87bd75b..e87e22b3a3c 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -91,7 +91,7 @@ public function getEntityFiltered( $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 0cc640f225c..8fcf6358008 100644 --- a/app/Uploads/UserAvatars.php +++ b/app/Uploads/UserAvatars.php @@ -148,7 +148,7 @@ protected function getAvatarImageData(string $url): string $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 50efdcdad60..b9289a5a7b5 100644 --- a/app/Users/Models/User.php +++ b/app/Users/Models/User.php @@ -222,8 +222,7 @@ public function hasSocialAccount(string $socialDriver = ''): bool 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 72189222fcf..bab28ea0eb3 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 517c5d8e4ef..5d0ce53ec1c 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -106,6 +106,7 @@ public function test_all_endpoint_includes_parent_details_where_visible() $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 5c21a2e341c..d5f9677a229 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 @@ public function test_copy_shelf_permissions_command_using_all() '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 186/398] 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 7029fa0c6a0..4785abadbff 100644 --- a/app/Theming/ThemeModuleZip.php +++ b/app/Theming/ThemeModuleZip.php @@ -15,7 +15,41 @@ public function extractTo(string $destinationPath): void { $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 @@ public function getModuleInstance(): ThemeModule 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 @@ public function getContentsSize(): int 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 8aa9370ed26..0086ac9c013 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 8ffc4ead3a0..ee8da11283f 100644 --- a/tests/Commands/InstallModuleCommandTest.php +++ b/tests/Commands/InstallModuleCommandTest.php @@ -175,6 +175,35 @@ public function test_run_with_invalid_module_data_has_early_exit() ->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 187/398] 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 20252525df8..114bfb105d8 100644 --- a/app/Console/Commands/InstallModuleCommand.php +++ b/app/Console/Commands/InstallModuleCommand.php @@ -213,15 +213,23 @@ protected function downloadModuleFile(string $location): string|null $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 ee8da11283f..c085c49077d 100644 --- a/tests/Commands/InstallModuleCommandTest.php +++ b/tests/Commands/InstallModuleCommandTest.php @@ -96,18 +96,44 @@ public function test_remote_module_install_follows_redirects() }); } - 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 @@ public function test_remote_module_install_does_not_follow_redirects_to_differen }); } + 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 188/398] 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 86362a2f321..ed33014ebf4 100644 --- a/app/Theming/ThemeModuleManager.php +++ b/app/Theming/ThemeModuleManager.php @@ -51,7 +51,14 @@ public function addFromZip(string $name, ThemeModuleZip $zip): ThemeModule } $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 4785abadbff..7e94074c8e6 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 @@ public function extractTo(string $destinationPath): void $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 c085c49077d..e96fc02c1a2 100644 --- a/tests/Commands/InstallModuleCommandTest.php +++ b/tests/Commands/InstallModuleCommandTest.php @@ -250,6 +250,23 @@ public function test_module_zip_when_files_in_nested_directory() }); } + 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 189/398] 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 74c50be3b2f..5501d2bc229 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -173,6 +173,7 @@ '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 e090708b1d7..37a87d9abfc 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 @@ class="{{ $errors->has('auto-sort') ? 'neg' : '' }}"> @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 0c9dc1f9595..5678434e0a5 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 7f31f9c2739..33a10609a05 100644 --- a/tests/Sorting/BookSortTest.php +++ b/tests/Sorting/BookSortTest.php @@ -271,6 +271,21 @@ public function test_auto_sort_options_shown_on_sort_page() $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 190/398] 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 97995738ffe..88c20e4d3d4 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -45,7 +45,7 @@ public static function validate(ZipValidationHelper $context, array $data): arra $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 c453ef294d4..e801705be1f 100644 --- a/tests/Exports/ZipExportValidatorTest.php +++ b/tests/Exports/ZipExportValidatorTest.php @@ -90,4 +90,29 @@ public function test_image_files_need_to_be_a_valid_detected_image_file() $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 191/398] 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 88c20e4d3d4..1611866875b 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -45,7 +45,7 @@ public static function validate(ZipValidationHelper $context, array $data): arra $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 192/398] 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 00000000000..c1945ba5c62 --- /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 0af8835ca77..723dc4ab474 100644 --- a/app/Activity/Controllers/TagController.php +++ b/app/Activity/Controllers/TagController.php @@ -24,9 +24,9 @@ public function index(Request $request) '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 82c26b00e28..3e8d5545ab6 100644 --- a/app/Activity/TagRepo.php +++ b/app/Activity/TagRepo.php @@ -18,9 +18,10 @@ public function __construct( } /** - * 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 @@ public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilt $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 @@ public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilt }); } - 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 44117bad975..6b9cfdd7d0d 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 @@ public function toResponse(): JsonResponse { $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 @@ public function modifyResults(callable $modifier): void $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 @@ protected function fetchData(Builder $query): Collection 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 @@ protected function filterQuery(Builder $query): Builder 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 @@ protected function sortQuery(Builder $query): Builder $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 @@ protected function sortQuery(Builder $query): Builder 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 8c0f206d0d5..f1b74783f8a 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 308a95d8c28..9f45cefb985 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('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 193/398] 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 c1945ba5c62..1fdffa007a5 100644 --- a/app/Activity/Controllers/TagApiController.php +++ b/app/Activity/Controllers/TagApiController.php @@ -8,6 +8,12 @@ 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 @@ public function __construct( /** * 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 @@ public function listNames(): JsonResponse } /** - * 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 a59cb8198e2..53cb2890a7e 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -195,11 +195,12 @@ protected function getReflectionClass(string $className): ReflectionClass 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 @@ protected function getFlatApiRoutes(): Collection '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 00000000000..c0c8e7b2231 --- /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 00000000000..37926b8463c --- /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 024a5ecdf04..543ef092ee5 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 663389047ce..ebe3838ef1f 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 194/398] 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 00000000000..baf7b085956 --- /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 195/398] 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 1fdffa007a5..f5c5e95d420 100644 --- a/app/Activity/Controllers/TagApiController.php +++ b/app/Activity/Controllers/TagApiController.php @@ -7,6 +7,7 @@ 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 @@ public function __construct( ) { } + 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 @@ public function listNames(): JsonResponse } /** - * 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 00000000000..6dd3f49fc0b --- /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 9f45cefb985..5a9df3cc422 100644 --- a/routes/api.php +++ b/routes/api.php @@ -111,7 +111,7 @@ 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 baf7b085956..a079fa63915 100644 --- a/tests/Api/TagsApiTest.php +++ b/tests/Api/TagsApiTest.php @@ -76,7 +76,7 @@ public function test_list_values_returns_values_for_set_tag() $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 @@ public function test_list_values_is_limited_by_permission_visibility(): void $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 196/398] 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 1baa11ffcfa..ba145460381 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 01964b066c6..dc0ea211f59 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 c4be0f3cf2f..00abe0c6d2f 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 d6af996384b..a3ada5c89f6 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 6082c59de61..c0d4fbc63e6 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -256,8 +256,8 @@ public function test_description_limited_to_specific_html() { $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 197/398] 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 2c6317af654..7a02656725d 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 0ebb8e72f29..ca1f2b8301c 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 00000000000..70f1058748c --- /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 00eb135046e..d3a408ad02c 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 198/398] 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 36dd977558b..e8127e6173a 100644 --- a/app/Access/Controllers/ForgotPasswordController.php +++ b/app/Access/Controllers/ForgotPasswordController.php @@ -45,11 +45,11 @@ public function sendResetLinkEmail(Request $request) ); 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 ce872ba88dc..4694f22e4d3 100644 --- a/app/Access/Controllers/LoginController.php +++ b/app/Access/Controllers/LoginController.php @@ -32,12 +32,12 @@ public function getLogin(Request $request) { $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 @@ public function getLogin(Request $request) 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 5c334674e7d..0a6416a1795 100644 --- a/app/Access/Controllers/MfaBackupCodesController.php +++ b/app/Access/Controllers/MfaBackupCodesController.php @@ -84,7 +84,7 @@ function ($attribute, $value, $fail) use ($codeService, $codes) { ], ]); - $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 c9100ef9120..181cfc0b84b 100644 --- a/app/Access/Controllers/MfaController.php +++ b/app/Access/Controllers/MfaController.php @@ -51,14 +51,14 @@ public function remove(string $method) */ 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 3af65d17fb6..e81c98b288c 100644 --- a/app/Access/Controllers/ResetPasswordController.php +++ b/app/Access/Controllers/ResetPasswordController.php @@ -48,7 +48,7 @@ public function reset(Request $request) // 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 @@ public function reset(Request $request) // 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 6f802370e9c..39598b1435a 100644 --- a/app/Access/Controllers/Saml2Controller.php +++ b/app/Access/Controllers/Saml2Controller.php @@ -78,7 +78,7 @@ public function sls() */ 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 @@ public function startAcs(Request $request) */ 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 07f57062d01..5a090c7ca2a 100644 --- a/app/Access/Controllers/SocialController.php +++ b/app/Access/Controllers/SocialController.php @@ -67,7 +67,7 @@ public function callback(Request $request, string $socialDriver) 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 9ee05b84fa9..091b68e5594 100644 --- a/app/Access/Controllers/UserInviteController.php +++ b/app/Access/Controllers/UserInviteController.php @@ -67,7 +67,7 @@ public function setPassword(Request $request, string $token) } $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 c4f9b91edb8..ed1421c0d01 100644 --- a/app/Activity/Controllers/AuditLogController.php +++ b/app/Activity/Controllers/AuditLogController.php @@ -17,19 +17,19 @@ public function index(Request $request) $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 deeb4b0afb4..65bae276d28 100644 --- a/app/Activity/Controllers/FavouriteController.php +++ b/app/Activity/Controllers/FavouriteController.php @@ -20,7 +20,7 @@ public function __construct( 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 723dc4ab474..b57c798254a 100644 --- a/app/Activity/Controllers/TagController.php +++ b/app/Activity/Controllers/TagController.php @@ -46,7 +46,7 @@ public function index(Request $request) */ 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 @@ public function getNameSuggestions(Request $request) */ 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 2ca9e22352e..2894ede3aa5 100644 --- a/app/Api/UserApiTokenController.php +++ b/app/Api/UserApiTokenController.php @@ -48,11 +48,11 @@ public function store(Request $request, int $userId) $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 @@ public function update(Request $request, int $userId, int $tokenId) [$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 fca530f8adf..98470d91ce8 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -144,7 +144,7 @@ public function show(Request $request, ActivityQueries $activities, string $slug 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 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug) $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 735742060c5..e620eb59c29 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -49,7 +49,7 @@ public function create(Request $request) $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 @@ public function update(Request $request, string $id) $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 f5f4a90bfe9..1e8b26b5156 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -94,7 +94,7 @@ public function store(Request $request) '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 @@ public function update(Request $request, string $slug) 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 6aa62f887c8..9e0c69b1776 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -64,7 +64,7 @@ public function create(Request $request) { $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 878ee42b5ae..db2391599ab 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -203,7 +203,7 @@ public function move(Request $request, string $bookSlug, string $chapterSlug) $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 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug, string { $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 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug, string $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 197018ccafe..38042e67058 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -74,9 +74,9 @@ public function create(Request $request) $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 @@ public function update(Request $request, string $id) $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 8778560e275..82edfbc2763 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -88,7 +88,7 @@ public function createAsGuest(Request $request, string $bookSlug, ?string $chapt $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 @@ public function move(Request $request, string $bookSlug, string $pageSlug) $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 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug, string $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 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug, string $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 c0b97214856..9ff2fe0293e 100644 --- a/app/Entities/Controllers/PageTemplateController.php +++ b/app/Entities/Controllers/PageTemplateController.php @@ -21,8 +21,8 @@ public function __construct( */ 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 f3165b603e5..5770d02f186 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 5de7a511036..3ecb955ae37 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -40,9 +40,9 @@ public function all(Request $request): JsonResponse { $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 348d44a427f..50a73910afe 100644 --- a/app/Search/SearchController.php +++ b/app/Search/SearchController.php @@ -24,7 +24,7 @@ public function search(Request $request, SearchResultsFormatter $formatter) { $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 @@ public function search(Request $request, SearchResultsFormatter $formatter) */ 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 @@ public function searchBook(Request $request, int $bookId) */ 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 @@ public function searchChapter(Request $request, int $chapterId) */ 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 @@ public function searchForSelector(Request $request, QueryPopular $queryPopular) */ 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 @@ public function templatesForSelector(Request $request) */ 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 @@ public function searchSuggestions(Request $request) */ 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 cfd068386ef..f3eb58b6b04 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -51,7 +51,7 @@ public static function fromRequest(Request $request): self } 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 e098d87f8e4..9c370ca272a 100644 --- a/app/Settings/AppSettingsStore.php +++ b/app/Settings/AppSettingsStore.php @@ -44,7 +44,7 @@ protected function updateAppIcon(Request $request): void } // 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 @@ protected function updateAppLogo(Request $request): void } // 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 b2b2226bf98..64e6c011187 100644 --- a/app/Settings/MaintenanceController.php +++ b/app/Settings/MaintenanceController.php @@ -38,7 +38,7 @@ public function cleanupImages(Request $request, ImageService $imageService) $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 7e2ee5465df..4ddbb14bc70 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -58,7 +58,7 @@ public function update(Request $request, BookSorter $sorter, string $bookSlug) // 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 @@ public function update(Request $request, BookSorter $sorter, string $bookSlug) } 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 ea3c4a962b3..2448b79b5d5 100644 --- a/app/Uploads/Controllers/AttachmentApiController.php +++ b/app/Uploads/Controllers/AttachmentApiController.php @@ -50,7 +50,7 @@ public function create(Request $request) $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 @@ public function update(Request $request, string $id) $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 9c60fa415f8..edcf066acd6 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -39,7 +39,7 @@ public function upload(Request $request) '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 @@ public function update(Request $request, string $attachmentId) $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 @@ public function update(Request $request, string $attachmentId) */ 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 @@ public function attachLink(Request $request) $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 @@ public function sortForPage(Request $request, int $pageId) $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 @@ public function get(Request $request, string $attachmentId) $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 f44acd997d2..8295febc1c1 100644 --- a/app/Uploads/Controllers/DrawioImageController.php +++ b/app/Uploads/Controllers/DrawioImageController.php @@ -24,10 +24,10 @@ public function __construct( */ 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 @@ public function create(Request $request) ]); $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 745efcde812..908322be07f 100644 --- a/app/Uploads/Controllers/GalleryImageController.php +++ b/app/Uploads/Controllers/GalleryImageController.php @@ -24,10 +24,10 @@ public function __construct( */ 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 @@ public function create(Request $request) 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 549f6e0ac8f..b9f06dace84 100644 --- a/app/Users/Controllers/RoleController.php +++ b/app/Users/Controllers/RoleController.php @@ -55,7 +55,7 @@ public function create(Request $request) /** @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 @@ public function delete(Request $request, string $id) $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 a8baba5294b..21816d5b89b 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -106,8 +106,8 @@ public function showShortcuts() */ 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 @@ public function destroy(Request $request) { $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 25753280f17..ebc17e262f3 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -141,7 +141,7 @@ public function update(Request $request, string $id) 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 494221b143e..f93c00a89c2 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -77,7 +77,7 @@ public function store(Request $request) $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 @@ public function destroy(Request $request, int $id) $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 0bed2d22a43..f4a56b7bf0e 100644 --- a/app/Users/Controllers/UserPreferencesController.php +++ b/app/Users/Controllers/UserPreferencesController.php @@ -23,7 +23,7 @@ public function changeView(Request $request, string $type) return $this->redirectToRequest($request); } - $view = $request->get('view'); + $view = $request->input('view'); if (!in_array($view, ['grid', 'list'])) { $view = 'list'; } @@ -44,8 +44,8 @@ public function changeSort(Request $request, string $type) 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 @@ public function changeExpansion(Request $request, string $type) 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 bc0543cab16..9734255e7e0 100644 --- a/app/Users/Controllers/UserSearchController.php +++ b/app/Users/Controllers/UserSearchController.php @@ -26,7 +26,7 @@ public function forSelect(Request $request) $this->showPermissionError(); } - $search = $request->get('search', ''); + $search = $request->input('search', ''); $query = User::query() ->orderBy('name', 'asc') ->take(20); @@ -58,7 +58,7 @@ public function forMentions(Request $request) $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 81d8a587636..9fb1b98ae8f 100644 --- a/app/Util/SimpleListOptions.php +++ b/app/Util/SimpleListOptions.php @@ -30,7 +30,7 @@ public function __construct(string $typeKey, string $sort, string $order, string */ 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 199/398] 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 4bc15e6e967..0d690cb2c33 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -34,6 +34,7 @@ public function __construct( */ 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 @@ public function index(Request $request, string $bookSlug, string $pageSlug) */ 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 @@ public function show(string $bookSlug, string $pageSlug, int $revisionId) */ 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 @@ public function changes(string $bookSlug, string $pageSlug, int $revisionId) 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 @@ public function restore(string $bookSlug, string $pageSlug, int $revisionId) 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 04878ada01f..0fbe9693dcb 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 c4d1eb136eb..3937c650f86 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -207,6 +207,7 @@ '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 060c197a466..6c425a2401b 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 ae115b69e23..94061ecb3a8 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 5a9eca7d2cd..890f790574e 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -79,6 +79,7 @@ class="item-list toggle-switch-list"> @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 00000000000..fe886a5d0e1 --- /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 200/398] 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 00000000000..5a0b9a09b40 --- /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 201/398] 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 132a10fa4da..8b46e84a634 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -4,6 +4,8 @@ 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 @@ public function test_revision_changes_view_filters_html_content() $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 202/398] 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 0d690cb2c33..cc6b79bfe45 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -134,8 +134,8 @@ public function changes(string $bookSlug, string $pageSlug, int $revisionId) */ 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 @@ public function restore(string $bookSlug, string $pageSlug, int $revisionId) */ 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 5a0b9a09b40..e4b51ff7026 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 @@ public function up(): void '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 fe886a5d0e1..326925ef93c 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 203/398] 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 00117f4a157..07eff14a470 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 f23352e0eb9..223a8c92285 100644 --- a/tests/Exports/HtmlExportTest.php +++ b/tests/Exports/HtmlExportTest.php @@ -5,6 +5,7 @@ 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 @@ public function test_page_export_with_deleted_creator_and_updater() $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 204/398] 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 22f15d1b82a..36901c0f122 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 205/398] 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 2e22bc759e3..f48fe0a67a3 100644 --- a/app/Config/exports.php +++ b/app/Config/exports.php @@ -68,7 +68,7 @@ * 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 @@ * * 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 f31d8aad078..df40bf44f81 100644 --- a/app/Exports/PdfGenerator.php +++ b/app/Exports/PdfGenerator.php @@ -4,6 +4,8 @@ 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 @@ protected function renderUsingDomPdf(string $html): string $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 c96a04f008e..cb0b47dace2 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 00000000000..23ef65311b4 --- /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 00000000000..c96a04f008e --- /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 206/398] 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 df40bf44f81..10f0624cfb3 100644 --- a/app/Exports/PdfGenerator.php +++ b/app/Exports/PdfGenerator.php @@ -68,7 +68,6 @@ protected function renderUsingDomPdf(string $html): string $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 f311f8457db..78da3b0c2cc 100644 --- a/tests/Exports/PdfExportTest.php +++ b/tests/Exports/PdfExportTest.php @@ -79,6 +79,39 @@ public function test_page_pdf_export_opens_details_blocks() $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 zcmd44d6*nmc`tlURdrQ&ZQZqWS5@!(GCechGd(>$i$)rac3GBWS(asEgh$dy8a&d- z8p*a;0%qU$Jq{4!03n3f$dU;OixUzcgb;8D4>y-flAD$AB!uwg;}Sgjey6H?Bs;nJ zp6~wksim2&?pn@y&%6BI?TAo9h)!NkcyjN7+gs!JJaO43QISeC zOe0jL3RS5_bsD8HYEY9})TVKopbmAZN0T%~(=0R`0`aF6MeLg)#UqJ7ri}W}>K~K^pdWxQ= zXXrA0A-#{T(6jU$U8U#g{qzD|qZjEV`T%{9K146m7tt5fm(Z8eU!X6eFQ*UFSI}3| zSJ79~*U;C}*U{J0H_$iIH_0|V9`e}NFK0&Y2YxGI_ z8TwoFv-G#=@6gZD&(kl^FVd&z@6z9+U!uQH|A2m(euaLO{vrJu{UiEy`p5Je^iSwF z>9^?9^iS!x>7UW>gx1b4o>&r>9y+nIcs6|hrPYf|Cy%el=PsQ+zI0)2`SdyQp#ow_Ld?=CE#K65d=xOQ>r z!t&aE!ugd;YoQaXXU{H*XD**Vvvf|xJ(gEbhSpXV*UktpTDq{xubx}tFFv>$x_IWo z(vom$_0k35)bayM{MzzEp|zz4md=Hi*iD6V%jcGGua(tvk+r3>%lTa z#TDuFg{4J27E@VTySTi1j$6F=f5H*U@e7M5?pwOZ<`g-OxBgJo{-^%@l*P3Z%gZO0 zFPyk^R=#xZB*=1N^}U%VjuNB)y!|4A2@S57YJH>iGm zacxOFwX%9)`J6gfIQCh1X=P>k{Mz!GcyjUd>7@&i;1^rgV6dlFmL5{D0vFG$UScaL z-hN{7!sUqPTSKB$icN zI?H~A{9#8fURXT2bawH=ed3z`Fn!};{-xaNQsl0a%S#uQFm5^E#*?cLo)Z|)g&7~M zfYHJyE?oehow&?jUOESAt=_kE4wRDCmQJvx4i9b+Ik9^FvVUJ?^}@+hU=zkQ{`^*! z&aV24DzB`bUOut7a&Glv2;W~?lP@ftUS>~PIw_uAJOP4;CzrrYU~aIle~JPIUAyG- z?ghW@R&dSju*t-%6p9S|q9 z6%2El+qxsXtv@7jM{x0u;Nl%OE{^ONTqW)a&e;>3v*)IB_6*M9cU(Lp?)M)j9tci9 z5S)JC#_8gLU_1u~SB1}BTDiD!N$i|)I97?1(0%`RRFE?&BEaRf^j zu%@^ioUIxbV`A3nP~{uDI-f35^LNL@p0L_U&w`7aS2Cb z)6*UN%5Pm+$sb~-VJ&}XX>IAk14}3I@!HZ-e)Qr)7xSlrn=TN>TAc;V#osZ$H9D<`)fJ~+}p%(LU0-ydOC5togPw%a#dvdyQ_6fVsDDL-= z|KwA+YQ_KT6rRbR$DVY7oW<37GWhHy&RWAaY|O{WCA?e4+2?S^B7U;FU-F+eirFpV zEO!1WycTfQ3XZmu!{i`F|Lkw`8{hx$K0o4*Wo_eWV;FfGuVc*-_Bc?r*FGhkyn z4l3vIzx+WtUI>X&I3g>m zrblCjY1#3F<9f+dI+M-i3&m2oQmxe+L(Sol(N=qGyfe|A>`hJ2%x;?7yk&mt!nW-@ zcJA7}XYVchZry+2;BAL)KYYiLJCELV_w(*~{;?O}+4n9UKXG#D)af(JFJvRRZ{_T{ z)${jXSi5)$ZUGSI)4}JKTKk_S&{_00R_VHi)#IJwyH$L^7k3IhBD^Fa#_T*=N>$AW8 zJD>af7ryw^@BZGGe*X`?{FSf%;n)7?>wo->Kl$djp8nHs{}~bJBY0{RgcOKKet}#E zT3!UseFygk+<(v8`D8wuFXpTHc7CQ%ESg2{`gNS2Cm#l`zIWq1EAQsh`MiJL^x(Yf zf5|5FU;kNVUzf>Wk-sK?yYc#acq2a|Kk;AxM)10m>9mKi&@Un{CNCi`#W-I^UQQk+ zuOP1^uOhD|uOY7`uOqLAE_)++6L~Xv3wbMf8+ki<2YDxX7kM}I+Iz|S$ot6$$S;yd z$S;u(k`Iv&lV668`xWvi`Bm~!@-gyp@@wQ1>#9w(nBSI86OD!E3U zB%dL_MLtV@oBR&>9Qi!?0{J3&iu^A5J@O^;`_R8%CSQSO{6q3J@<-(B3;4<>qVM4;GpLzlB@*^;do5;Q7zOG-qF0o}xa}l5VEvXdm`2RY+j)Zx1OT7;Mm84RS z#}}W-z3^Uh`B-*;_WsQN%<&_}@uS%Tnfo)f`;80S=f1V)HG7`MAG-UsyTA3VZ>|5$ zYoK?)g_-M*fX4qsHbF`6CFj|Uu8=t*H0NUpXQC^F>GUiYqplRiuoJ|q)yF&A_exTj z!?#=J0Rj??ZF7_ifhP;Qu?$Zv(76QHd}1eccXLgji6f+gZ^?ws&Ls&7=%9HHn-mz! zN#tm}3dX9oDB~iqTD$|L>%V-CtJb>y=T4`Kx23o!`LEtYk6mZsySOc7oJ1j;A0Eo* zO*3TejoF6j^!5!kO*IsX#fF-PCyJJ$L=wHzy|?+)7Bfm1|NaTY-*npd;9ets0M|=}rX4 zopFDOa28)}alSAPE@5h=H#tXfse{N+DN1oxsm6;EuJ)BoZ?faBevV_y?5{kpS#uK; zxm>k2)M*Z7($T2rz0r)os>hsEI+50N(<_wzm!X!bF>`*blZi=@T6Jh=r<3%WlXKkq zM|<>*bzaQZhDObg*_p9QETL=Ra9HAaC;&;wOR6YKYJ#p+Dl;=P+w%p%a~t*1R&y+u zTffF@s*@}fYTabg^-`7Ad@j!&;ziRGE0yVNCY#<~C=Ly6-LlmbQ`|G};IJjXYQeP31zJlcHA)&(9jfD=5wZa8R97}bXy@Z6 z+3JJq4&I9!9X)dRkRfj7nmu2bv7k(Z$G31bvBZR$tJbi>TPXXBwS@eF8~XfE3rtG2 zB>6mq)wOdpSYwCr4=!ML^Iu}AX!UuD@sodt>K4Zt8lFCNxl$=Lca0|ER-#r(giM~s zWhG5HEf&hhOe_C&Q`V)J6|cPKeU@3Tw87HpL{k$vz1FQIvv&NK^+-5m#wz({J}D`- zG&!_$IwC}L-Ag)gMcLtfYJ6naOCD`HiRp4tt%c$tOQ4RfS1E5MB2&p&%=37GkEBbP zeDgbUBzCvQsO&0qam{lV2KvtiSs2b;*SBb@O_-)yy|yfbQtp&`ceO%=R4$+QQr!K=#IRIPxpjAZy4SOZQ|YJyGeF76^`o@I zJxMZT*5^e_}<27i8c{O2MMnnn;VIl-*}>p^H4okGzxqm{ z>q|0Q>ye{(-7%6*nz3+Lk@NZa&8>E^62&N~`q`z^qp4IeH#WLy^WleX-8D5?&ZI|L z)0=0@nF{nANnw0BjIT&G`QwWcB4~zbCty+ukPmFFphXSSKaQj`ylaOsaAuXRW-!2T zFhB+o{8wOr;x^RKK-oxf#)XqKm&-SsjbeEwpVw4M`H&z-v+42MHcjWVR5-K$z(hCg z8mYo~XV0GdrCe#!bF)rvHXWG_a}3OgkB1x&0KnuYPT4q zc{PdYP5RTjqTY3{&}>r}+5}V4P|p$!>EtXENT#a;4K6{uGWA>QYwa!E25_Q@IIh@u z;e5!te>Z152S)QnmWePEeB8CoD2A8A2+7V3&|{1CZ_%Z4!mFDpE2ed-L$!L9Lfnhd4=6 zLtl|$K|Vud)W0v%Y8&?jn1XKxiZV;t z$4UZ)&1Rk$ElGT*M_YGq|M*?oxGP^?CtvX}bm#iN(C=}NW6dYYN-)zhQeUv;MMiy9 zaff-5$1&s{hgccKsAV06cxsM2B`uxnzxJt_>*K(G1_ZexCdmvBW*Rw3K-dU0}M zd~|rIQ7_ApBn$vsH}(nuR^I|J5$+4S2>3kN8IK!G>;buo?7ObVSw=qB9O{&^Ik%nk zxUe117K_#Lmhn#3h(vx#QyV)4F|2A%O;$o7da1E{VM}W?nTp1eokItXw02ET*XpL- z7~0*e)DO>ex>whKdN`IZ>1HvPN|&*A^WeDO0!>n6#;1q^mE|Fe2phM-gndU>qyZZ~ zj$hyiMjlG62somLQJ3O{Zn1_J!x>$p1WoND&ClivQ}jX27G;sz(U|%3%?UdaIWwW@ zp2Ksz5i4`Io=th0maAdSiHY%ipL>in$e2GKEfj`08dZ+CakdQ1SUgU&g(vkWQ#XOo zUC~lS(7hMs;sy9%zA-4}i*NCpS;f50ALIB+Ir{dSc(s;gP17Y7Tea zwIdxX6{{6UM)c{q?X%9)`KTNb#a#=-y_s7(ji&4U(TD@Ykj)j2VT~|e{rZpT)1cp6 z-z$a)!~X&~!9g;-ETmN3LU&`}U|Rv~V)=Y}g;d%Yc7;(7{#KtInAO|h6J`o;whB&v z9yw}DfqA6IbGfo(47-l&c(x_9+TE$i$>E`-S1fi9kBx0_4>e0AHvzCyZ)CFq_sYYq z5nX*pC!6Kfay+UShS#=j(<~L+qjL+REvs>Op;ED|QfXAJCK9Gy_l}iJuT(0RAwxFS z=g%=;lf=pXK!!*RfdHC}`cVL)8zv8^;cD=c$DWAONHh?YCyB`+bFKO_2Wf1^K3ZG` z`#fRcWB4wOh1PJ&*FMx9i`vmGyQg5Y*U2q9*%w%uR{c~9(c91^2{wv{#VOFaZ z3v^HSbyt;aA*X}cVXO$jxOK*A(QJ+*a(cWcbg3Jgz$e=9+?JYv*${UK(;0hmcA6=! ziJKJHG-wb6nXagbQKq=mEpxNIE>v7NjHp52Lp+BYnaT?AL;xD&IT~P;K)f&Kd&7?LBgSn|7sd-Qwp zsIJR0=mab2UH@0aX&oI~p1)dOCO?SYa~Z()LuD3k{+U{B#dsgH7`gk@-#aiq@Q z#^FMoxp^3?G9TGA16vVlPqFa9fm?U&*uEfczU8Jxx)qBQ_=8u})C5~3Yx8XfZrQ`m zoZmDvH8~!IU)${6WMBL^4c5r_GX|{7;3Ts`;OV#n-;Eg@TmxVYy~8t<>0?O$qrm>; zlze`uK0e!bf06QFcMgL%?o7r{87RjZF9wgLpLN@1}34OT?pn495jn3x}}{?b)_~ zJO^sg4>djK_@iaV^PF;k?1J~ecdkdj>v_dK-t)Zm-wTeC$s$70{6A(KV!x}>`<*xsfOK~h_9bjx2JYIy00WE>> zf8YZn2-O$$_6XNSRD(;cKS1BGF3_JJ{M09pet+NhF(1&TK<%$AO9@L=q6JY6g#|mE8g5m}79!x8oV_UNTGmV@m0G>t$z~dvOe7w$B3ktPbNKwe z>p$ct;p3b_4(^=+oz@tfBIJIiv{Wt${k06*pGIu|3MoF1MV@XQ2dS=}y^p&UR{Gk5 zmsa`lB7*#<>FMVMmg9;lJjj$-?5rP$$l`YDV!9uMz5|3Dy5TqNz~NLt*K4QmTjtY4 z@NzD#-M=ct5Iq{8(mwqL$kg{|0+i}gdP5aHOBqh~YQ8=U>sfuw z&dv+Y>5OxS)tH_et<*di5?;{mtmmVe8c|eOjHzbRb&{!(;VqkY?cTL>Vj`O@mgdjA zV{X(eyJa<%bQ8_Fnd#|z-AU|#ds%HXj&9plEX87(Or;zWw(ZJ@mX%Doo@pY`(Q1s) zr=6IdaB9_I#6FD6o}vMo8I~GlA?K;ho2D97Cy`2xjqTWR+qUgHCvK05dc-Jc5jmtf zPQ5iB&W1&MHK|)zLs}gAzb;3dWa&xRH55# zBc@0aGJ$`WYMuJh>qjzXMDe_E*efnBMg>9lM9K41)ll@1T;~c;{{*oFR{uh{`?yCj zyE$?jd51qkEzv`f3`Kl~bQxF$4H7%R*0Eho?%fV}R3ACWA*avRq1&57pu^R%7T4?` zhB`aVby-kT-hb;Yd$w&Ao4an%=GuNL$#md*XIqTr5sJspxh+6S*SeiCK9a@7kix2&lZbO-G$ZXc#xV)%5tqf9gRfQf`Jsv`ak|u2w8TqS=iik zRE>-3rU@0>y8chxW8kq{`hJjFEZ2Oy;KCaSqD);xof!HVaM{%~v_O!V@;Pb*huZ^= z+6Z3s9f3fk1AQ4g6xCXhw65yr}kbG7fggK^VB*^pi#`xwf2Y2nA%|?uvZ2>*l zo@ce|)pVSN6S*+=`8bv}e90~zNNT&2BjY&QK zqaO4FB4M!^6V_7gHpcNV+HrLZ2Y zqp16JF{4)Bv**6`uQVJ8CjXIWG&@y?i;i5I98Z@joq0}JKU<9&k|a9x{^r<3YphsE zgasv%beo4X8NjlQXmEGOc&F4dOrZ%nfnS)+z7lkpASVKOCDB5WAEy)41&Mrl6;U=$ zq$UnjlB{zHM2Ltt$n!Ifc~X!$>E;L#j8DNgKDdymBUOmHI>MYx7A%rjoW^$(S+cP| z|Bg`#tOYXxJ|Q~YzBtCuScw+5w~^iPTV2~8MyAhJ#84_FnP#rgi^r>5MqgkipZR>< zwgut%u%-&$Hy%@z%ufYfErGvM^w3l>ujzl6i$v7qE54dkWFuPc_UFDEbMN-Gu}buO zfhX{ySyBbF9ib#N+TzJDU>Xj+8W%^IJ#mpS-;f@^4rDNSKr~!K)KPfdSJY7`rNNYa zU0lVC{q@Eo|Hm08AUZeCnJy1Ij)Ew9+%XWycARj`%;m;yyWAUXXEL()%vWxjvnPsC zJ^%Ltuj%=OmB6gKg_0KiyNnW1wMuzsf8Gp_mY^ek0v&PJzzPk+OU37IJ5Uiirr>Hh z%sGOr7C@Rk=D**{KA+FuH2JH=XR8htRoMTZsSc!&1x$`*Dwr{H>>JvHX^OrE*^q%i zhcNTSVq*gh!t~CFY{7{fYK$%Hdi_hSSj;rmKc3DRiYV~hDKp{9Im$274A&{- z%NxqXQaLURjXX9zIlC$6q^)KlriAGu2RBV_st@OKdTj2Et!N~~aRB$BP$U$VA`kC? z>e1q6xja+KX38@QV-wko5#7)%K&wJ1X4#5vf~S%x+W;Nokg1=7ueOrKK(i2iqRR{O z9IRl#TG4cY%b^P+Hs0agEKKIB1tI;qsBc~YC1<<(7) zt9IX{Api?n&CyQZHsRQ(tKp-r4ZdQ;&(M;Ov#=cgV%!&vPSJxSPW;Dl#WWN2a5Ju| z;jnEM*FR}$yduakDg0yG9a(?Ob7RPNG}iZNRl^iLPl)Q(zy14i6sp&AMBXr(^gGE| zG%UF;kM0#D;;sBsUu&s~=(-&E(*}J(9~MVk1AS`!xU+6L5G=k59qr<~8^m}rqH>Z# z?e-o=Tl)AfTR7&mwQ!-!8mifWP|#;9g#0ZlbH zE6?;rB*J9HRao$o*;Yso3k0dj5JT_Lny(Qn?2~%1atx9cSx%ne5XR)pmv$0mL6If72vV5>_A-aXkgk^*yx)RIM zL!${h{RMVz{F^qBjZ%Zw#{?h9)t3Dz~W-dR}3>p67KaOAR3SVMMsLunK zci(Ag`^qatJAFF&+`&GBsW2vB9q6~eyxuWwF&wpI&Hh0oCK_Q&)489ee=dp$tkvkg zq7_lX($SxPjege+DSB}|nbVPl1|d8sZBDSP3jN+QpXOe$9?3-U1sC$X5RXu)o~2J8 zbg}j^*vZd<4%I$30v*`OGtA_J!%~@(ku1Zy&npRvq(%WbvHY{R;3oam_tq>Lgs0{; z;-xu~DwdFnNe);dDu4oPmKN=>wP)3Z%qxi-$h?j_XLug@chmgqi8vD3pHJy(BKw^Y z&t3n>w@i!MzCK+t3}oh+1q&hdM?d_`Uvh`afY6~N3PMNLzeYcmw2}<}mahMh`vEWj zD<*%5zseEBsiD3KY;suS1su7s_^>$^B~Gh7-a(O};fGD5NEIOZ?1NjSa8p|0+RQ8> z@qtt3&$k9Z;01tS-Y#t2GB-6j(HUWC`9&Rp4bL3U~y`o2wz~L z)CUN2w8EuQ;}Z)z_D!}%8n@Q#g4XHmz2(sHg9k_5SoASnbA}HO<#V>BVELicW3gI& z3%|Kqvn;w++qrGU4XBY3pdN(}7{&Ys&eI}O%C;@#8`4!g;3^*z^Gpi#j`8`Sy#z+IVi zuX9VdSlTpqS!pZHtB)cBpmt6dU!#2ZM&-Y=ToLrdS zEJQ{&n9dI+_1VscomLQR;%}lbAk7U990;%cWc|yYBxb1?&F6BNWISQT zo9T4GnN3a8WV=-A9?7L3$sh^SdmL(lZzHQd^^|=e(?Jlh$9NMN21KFAFiU4g$Tr5b z3)^o}=+~lk=(loUz<_D4A#{?Y-p29OSup2bpE((?UfV^b?Q~n;A?nwGFb&2e(N|uO zTiCn}*Z*0$|DY^j!y#twA&|mN{!r7?V|u(YadJXCX8pgV!nzJi(@ZA&zE2MP`BLy_hHUle zE3JN616^yfuGjni5@*Q zm9Sq}sVG_QnU8W=$%)q;H=SDR*p}zTpk0%rqx--5)wy!nL>Z&w+w>p8rhgR6eaIi5 zO6m>H$8#uDVzBo=G2_=@=m%EKH`@@kRGU-BVYV5|kTNA>$YU^6#}w_2ta%^B`&K-_ zIlj^A`zamdd3=xfcr;opY#HtWf2CUKu9Ju;jTZ1B(TDVBN3B3REX+=>?XrfR?4rxpB{6w z2-OGzO(uB}C4GitCRp6UMospgfw#u{Se$)^!ckTv2Q7!LF&>72Bs1mybmr0C5k`4q zh&6#UhC*r+`7b7yPZB9=TDJ_7>gCw z|Gc0`vhvN;weZJNU$)1eOebtaYS+%noMBehzhcDWa;Weiw=kfYzmTyG;s0y(ndP*NmmX>2LR z#yOVPaWgYFtsKi1q_W5spt^P=TQE+^4yx{+^cQe~l3lwwbx#Kim-!|a4TIx6&w#jay&1g0O~a#QDlTI5JE;S1Fo%v8nX>L zeiT0YE}xDXx@*yS#}D^|d=Vz_#I?ZFH;ceVKrc$HM7b)Ec&*N>4I6~>YlDEg{GzQn z%EW;23KMTo2Hh6y1K)8^I2Mf?R*yc<&ghc+X)~r5|9dlT+Yi515(Lv=PTHp@iobbj z23{$=5`mZO#+PX!6VsrA1*BW-q3=*ZwJg;4K^TP1>tEcAIibLedog$;O?v*Emjnlw>XyY|gDPXOB+>3buWsQ}+|Fb&vpZ7^B@t?S zBYnv;{~FI|X}1)0JM-I3^GBCkCJPgQ#P`R(-f7f~upsi(#<&))uh0?h%F`DM|@-qJP(~6XgeWqCf>VsBn`q^RXk5ctq^{*n`vIOHbePkI%Qc zFEe?|VeB%-4xJFl8ygz5AMw3A&<$}_-VI}*L2PF*T7uDH=*Vw#6qyPPJ!HV5Gnb8z z+fD4xBLCwYTo0~!j~&70n@a@RDGsCM zKl1RycYpfe6S&G)|7Uu3{hhc6x&BJ}lk3;8_(5$!pQPZB8zT({hm$clNGxknd}*hH zd*CCHGswSjU&S=?Y~ELrRNkPUzQEG)C_(B2m=5zY{9tUt`9klF=$uoKHs`f6XM89U z4r`iKbR0a`L;nBoQNOVVO~gTsjtuVovqd3Wvf& zRaa&t&NQ-`6xGr45(#sZ=f!B$I_@OoSk$bHkEHZ)cvl&rRw;s>83|pRJhDsWY~~B^ z$mx!gFQc>o^fs^mgo}WW@}x!H?{jZ-l;)7eLDwhZd09W6hmu_&G1o@uaSS~wCNZeA(bvY^^`MmEEd}Yrw}opHzB&?KJ&p|PZZTerP3H0=}z~`Wmzs! z?wM;;n|rTt1T?GkU$L6>zDUNo4_> zqVZAF3YliQnDkV5KS>X?0*_$coC6+dp}X`gfp!Ko5TMZ9Hc-*Lk)uUgo7=Kg7(&q2 zEi^FWc^Qdwc-3MX8P6y7P!TcTLDHDHHgOstD3ECfL{`n9%7l>&6_0AA*so8R1bK%0 zT%xt%*duf_F&;?|`?u7Yvwo8pZb0Ywpi9lS0wq5Po(ZJ-rhrg@R{}X^wdY6E4CjLn z;?3q3Ks+^-FK*JEa6ML1weRE&T^rk7$ra}WP^jj_;~H}#X&8-3VM%kf8hs?!&3gGn zE}2esi$zq_MN8XM8R#SAL=jk+MLJ%Vy)Vwij6dn+Dw%vVn)^2n?O#H~k`L!Zf^>kfK1~XcLFJmdBZGVq432-7hkx! z9#eoLEp7s33T; z9x@`lA|2N#s+^1jYK*Grd95p=$tq1h#@{qrQC@6p8q8BjnKH zot;z2U&mrcj-at9g&K8PR^_>bo6Qx=)mbMoHMOtnI5DkCcQ;?3M(2}c*;2}gghJ7F z35iNv(HWmcHB*l5*pj;5p??LvG>;8GUO+zQ&pUFfZwL>dP97bHN#u{Bz;Rr*eE80z z=ugTY!QAz!O&n?M+QaXl?hMoI^AWUyZrv72vPj`A^Z>N^6Nl&w`*j;VAP1ZT#4^wN z+f;(~HlGmNK#0JuLV^!5X}%3$_llan8y>q>z0FTv5i&~ETSkB*V5DzFSxsmJw)Tmw zbTfhVICk{TBSIPv8W>;5rR~=oGq2dETij>9pzNlEh7mNDc5ZSq{4d}YPxgE}5adC+ z6TAe@Z!-@QIEDzODw0fE3ciOcinPj@@OR*HR>ay`8c}J zsOYJzh2Y@&_hO~3=oXBO4ymg9y9G_B)P8xdRtLKNQrwbt-K@2j+gc`FsMztC7zsPc zW0mr)?PN-pxk%XPpoB-zIgtuZI-SY8$>?Osr7zDU)6rtFux-!!PEEIEX-d@^MFo7! zd^o0a>-{Q7c=cSgT){RBED`LNS12S4y_-Nfg%yAegq(SDZVH|pHYx}_Ie(2TtWmLV zX@W|ozW3%E7}i1r7DoC2sM!}~0MW#DRZaicLrje`;)z7B^}{h#wH@60)C70WGwW(n zQ6$Gf$6O-$Gf&s|Tqj^zX^6_3E9L#vbERWPq0cZ6z*|Ib5c#^-=S&$;9e$I)eFd|b zCG-tp7s4Xy6k#XN%Q=7dQFb5h7eFr^NrG_R&|UDMw(z_*LRGW~~1U4t^|(g}YW zsMlx3AJ45e2A*V|0ZaaYmw_pto%!|WY?MBm^-v_8WwTRE>z(BX=i zM81^T*_!(*CYvY(q_8bhk)Ki2?7Ek=;-(ZgRR=;~x}^l}Qfi-}->Vg|jI1^xhxk{E zobqR_5_GL2R*ZQM6syogD_j++JC?XePz1N!IQm#`;!a+)5`q-6<9yJA<#Q*I3N-Ie z-p2q!Ogxj-J@y zBgJ~9^$9q6b;R3eId_tkL~dw?SSV~et#MI83s`$B6m*BMabDA?w2SWa7r;NRQfVul z?F*HkZe~O70%W5Egax1nGidkx3>#t=8@w5OH8MGCwrL^&9K*r}_RK7!Fq|TZf9JeCb5BTKB#X*TV|EwEpKH=JK+f^`EdE0$Q7khoz^zUb+vr6a=n>6eya?l z*nnOGC%8|Jy^I`jU2Tk|Wo7IKj;ac|?4DY+;yq>SKpPt~M~4{Z&ZB1Y=VM|pcd=Wq z4rT}}>SIUrsRBD@P>~n(dO^KTP}C$6m-VvEM-jz|)Q5%zI0(xpbol~sWD`-*maUs)50W1t0XsO?xn&B7}@{zjl z)OcJjKhfP*%w;{+of;_=M@A=EZ)z+ezcU(b@5j1?P=b|dn$~(|TW<ER)X(?OU3E8h(L`jNhV`9@H)(gm<%lz~2|G29qP(sxF8Z5|#5h)y(z=FkRZ ze-Y_=w7RJdHGnTZ0w2VxTTVia>ezH7Vbof4)#}XjmT9QOf@Rkh95nl+)@4gk!w~__ z3hEX1PvTyF+=9?i!efkH8tbhAt<52smy)zxfC^L;F4%3S(1&{uV7)RLcue($}grh}QtW1gf?$DEP!YnO+5;VWQ=GTA&4EqeF_8 zq1f*LeUGTSGi+>f63fK^5=|?;#6!0_NLm#WF{-LtO-jtItaHbg-OcV|C{vrpHMT zu|yy=_~iqbDA!*#KQ>bg5|({85_1Y>1I4_{&rUZ-P;^!TOZaTdsYK&BB2%N^z?`6E|quFRQ z2Fs9&$)eTH<*hV|GP54Gib&1A-BuJo)^DRXa3A*|sgcdT6zCd7X(nHwHR@tNk{j!I zRl&q>K$m$rW>jl}gwh1P)mByA<$Bsb{ zqK3l^hg23Xh^o%Su@*|!ovq4*qU7mV^AkMcrE)?>fxPI%qN?rrHQ0#Fc=YSQWP!HR zlqNTdI5jZJ2n!Zyi42zqI>0n?%*DGJk+=~*en(n`l;;{zEkaOw_k>MzaKElK%0+$z zZw8Y5q}0HuXurfCzX0rt2w4~4K?jKKar|lr0Xl!J`=%i7FRx#a)Kj*LGP+hXn(>P1Fp7=7ioM+GLrFdQIeJY(2jg|pC}7_uA`V{uYtUkp%=v4mKyT?L;>D30U4PH&c$6uo8{g!_AEiHlP98+r^!KEYhuK#wiXt2|l)K2Pq)R_T zBeSa33qv}6SsZ<@tPmUJxtjTf19wDigjkW+ib3OW4pdMgbNBxH5+3Hk;%&c&d9=yx z{yd=9NCJUHm%8vIpf%BsL6C&1p*r%0enFESYq#4lTJwe$OXu>9VbuL)vZ$R43>eZQ zev%SdyTJ_Om>#RXVAIVBP`iVPJAdQ6SUBmtLy#rpSYJC9-aR5A3xWvr4^%^zquY0@ zeEIt}!Vx-ct$&kVreW6*Eyt-Ap8SZ5`YBXkIaE-R?N5H8kwriwT0~X=`2ymv(-gVr z&rco0{F37sd&{pFEfLl;%Zf(JrD8q@hcIrTL=+hy^q``fHHRj@=CzUG3g7A%jRp&e z!qG`V8tzT;%^Qjdm7|$FDo3-qjml9d)EswHqZ3BwQR`LJt0^-wA-g&Zd-8;5o_6G44JhyDfDeSzHS(7fyw0|TWXu3E?H+1Y8!0bpAUuan89hE_m`K-TN<+D9qBb*CLEw_Z zE=qFjJw1J4mj+m^W_}h6=?Z<>u``RO?!vxqA?%=|adzCZ^r&px*7WiBzx!Sh0nIR) zKj3y1#o^C>x}4U-KDWqLn8`AD=Vf4lzXa0-1=j-AU+c;1I6 z(2I#}dfx55btn;6WFGD=6dxGpOseQWO$a6NLCMFENi;hvghYA#;1Q0tQwU zPG%nBe8h9Gzg9>W1N90^f|{Zru2G?Z4rL|w%z$)JkrzZ(`>TUPaB0_%gas~`C^_A@ zD+q_?w!bi`+jc3fV+*}V1abX%-pwqdz*$dFQMGK>8F}P5^pp0&&yHtw8G0xrMo_L? z{mrp_PDBrU$Xb0OpG+u2YRi6UZef|C>AkmYAQ4K*%xZ?#7{nAYS@#`Hrc){8S+W`L%N?7 zNSe{+3ULPeL|h>ee|xuhx*xoe941CSeq$KU7q^-(h{z{?U^tUaR3dRFgIy@nVJ*xd zuIO!_u3!%~&oXAVrRZL4i?rmX#)wulNT1eorEwv65jt(NEhESg}J`E}Rc#}%3LLPrEUuJt#0={S|lp;c1A%TPx z5alMD+-g0M7_w4{{HBHBtB;R(xhQ&!MS*SJz=ZlJ<02lw^^$*%gC8#!{)#E>HotP3lN$h zl+j~&843_7!9hsARE)xB@9XEl{TtlWf#JiM|K!-UeAr~0?rDj5I0H8C{cO{>2;D4RS+$(!HGso0`C2q-Iu zIB9}rd{Bec#EySN+r(s&?R2pTN)g+a^#Rb;?KqqqoOwSx^L@);3+;U$gn_C|u*G3P zC#;aaK0!JmfW=rrEHM7w$T8rYc(L{zCSX9ZuOT;TVkCB+e{Xp4gSBN_$xe2B?TaVe z$kD*9Vhj@SP@hS_BKVK1MD$-_{|1iBnyq5A6E)%yqm0U2mMM=5$fFrC7k;u9mt-Mg z)JqZ5MKnxv4Jofg1Z@5m3Ws76)15*{NleV12gRR5!Sq+|2IicAxBc^+H zp_{BTRgV9=D;iSu_;}O`{XXi|);WV7sOs zFW}p^z7&Tj;(qNLe+fR`>wgjKJs)kOQ)7WubWD#cb+iFJv6n_)k4*65+547-rTbZM z@Xa53|9jv4PVue37~thAs+l|fGibbV^*Hz?}6>31hzj3Vwimt!6c=FGT^}1 z47I3_=Gi^`q9!J5C;@%eZYRtsMeJeYL@3Cc&h2YiA7rvKaqj*EqJr=PriO=I?4czn z#Q zo2eA1K7Z5bFk;B@hY+X|Ma}fwR94ea)^$dUO%65DiY8^#jYOwjw=C0oaYGW-Skg@v zG)*=0Ic^!HscJ+l6vp$3L?+ixCd(Dj%R$PM#YqhAnFqMP7;-S$b#xWBVy>s5LGk}7 z?@XX0yUII%Usb90ecww}rBX>Mm6p=JbgNqGR_~j7U$EP48ynijCdRQDhhSrjVQ|3M zh7jx!W6Uy{G;WZ)F);}^feHH(vn6IpfFz!r91h8NI5`|p&F|h=*aG=NkI#)h7SQ#$0_1>+47ukRLVY@vzM2MAd)680 zWyE;|@{s|cEw5C#$FZ?DUUk>REmtP$L%aK_r;amk4zLRD*FK18Ca1WN)O+~DEjS4n zwK%fPqM{=#S61j~GA23PaLbhAZcq-g|G4_tn!)k@Fa|}Jx7@iSVRze@`0v@V!_?o| z*8vc`wIvap9KF6Az>gTr4e2)=s{MwTfKTAnc3eEJp6ot&=m;KtW;&`Tz_=r(G5M4jXAEl`c-@9cJH$6Z^qu6gT3gY^XXw0uR3a{Qc9!< z?G54ZtR`tb=ACV$Qb?0GU8^}a62&&}+c}B**>`NS`8PK_-E{FwavheF zQ=0CHt#mt*ai~BSegTc7j)}ofl1V5Wajqd08fMZW%?uucG(qcHK*g9UuZhSgM}yI3 z^9H-R)~ww=cBA&;QX*5%mAWQ^9y}_8v7jdb#=boqX3C2K%bKyfz)dL>qLX+^Ts6Jp zDb3~aFSIS8vp#3&a4})lDUQ05Ymj` zqS-dA&CMp$GD_=LJ~K}I?!xt7Hkt2Qc;jqqmPOUh-S6qn*t$a-29iz}e2~5~t6eV^ zkhwy2+*cdevN!xuoK%vz_R(X=kVIi5qJ_}D_m&e$uZ%?I`TrMSywA(NoB?b8pP@sXSzSjMa4>!h zG1wXNGCd4zkrWHPLZU$!<%OQr+(ndCs&59%21^f4=eqFFq|2B2VboMpRFhiE;!jOK zWatmEXaFHRdMuR5mYv&5F}qXqqCbg6^3llE!Az#t@loxYNxX5T+BA}#3ndGMOsI1- zo6Ho|;fQ{)mNE~QJ;7hN4OIofMntw^Q<+9PM&GetV_##lhKM@fTmK=xx zw}wnNPbC|gBqcHH-BVvP728jBWYR(YGO5mX_5yyiX4+2z{%mWpDC3m_fo!JOk;(c4 zgGHVr#2Mp@nunEfUVjd3jwK?|?DcU{k3&GqD(9g{wum(O&j!tN=ujrDU$4ESu5ZK& zNQI#EIyl>(RIEV#23WVrVo^D+F7y#WE4uY4u*$O*BS~4KqjZ# zVl@$29JjlSPB)k_qcNHLC!%?b&*RgR#8*=HueA$M31C5FA48@Mu(-70>4^qfnm;A? zAX;jGx2!t)P`PQ?UMe8;RNXThwiq{QV>sK0jf6-k?Mc-u63Oj5cC`p|IAU~41F*Wq zHlh-xmo6){o=Kx94-Tza+v1$Upxzgdw%rL$XUSL}v3CXiZ zu-`Nv(65j(=1|_DMWR-p58EGsdx0-xSwWSCJkbS`-6nljmL5hlui5*O4yWOr?nhe8 zj>KPC-GsIqj1EWk&iB3Bu&v;6F8n!wVysbQFciMc+I00`*oX1hZvNjfkJAJoi`~)o zorM>);xAl4{mVEi{;XC^J^1Mu!wWyrve!XtzqBnIK8tO6gLtiK^-EZn=fzC5Nw7M7 zNgK1i?pZf&uvBN(8>i~5i?P0XyK_+&m{w_izPBzPt=tOzv!|ZAhH;ia7D^xsr5dsj z@S6@Y7sL`~5jPQkRX6%Lq?Q?6q+X+3W3xDy-9+*@3!fv0b2FoDW8-6eE(b{z3{JNv z+gbI*9iX9EJk!aMa>`WhEq8!kX-z!t4;9Ez*1LkITn-L+$2%dD-P?+t%qsd8vA&=KZ&U4K=SOht|<-HlzJ zC7>pXVtJS!ayzOpxSJw^XCkcmfQ?#8JnH$SrpYa1VnvQoQ3VXXyAOCK++R+9OO-ss) zjb8Z>KWls)u>@<{Ch|Y!w`8n~7v;<W*ioumZj?0 z4MH6u+7%-Zb~#3E|LM*?L%A8B#UvdA&yLG8L;;gQzOF^FcMc8qluPDt)0nGQGaLaS zJ!7Z<>dK%R2}e|O7f13+*`b@rNaJ(3;GP{BN&AUc!`__=H>GmmSF!3qZAGp&C1Iy` z$kiW#7*a(m<1wUofh}yNPckywwxfIZ%(dVXQj*DF!Xe4o7l(voT5_c#y`v;+k}F_L zR7ChF3Z}@!qUHddxQmxY0_ya}X`FJ$uH&7F_z>2Ha9e98727a4QXo54*uFmzjmFUv zz3#T4r^Vw*Wb9^($!767XM3nucaCoeX5F!czg-We8mUfD zsTz?^@U8zBvBuyrT#tQqOdM2g^GYZ#?AMc-CH-3Ed>U{p{(fyb=(%D=VVAcQ-jX9DYgaWh? z`rgl@q{<_!ZmdhHp+w%msYa7?Y+?!%zTFsA5~h>TnqX*{*|jXETc_qGEDRXd_?j8$ zBdvO}QyL10{P{McQ@Q7pugKDB6gEul&McY^)j$0qgLG_F{gX?|eazUs(=$frdXyb3 z3h0%Q5QR<`rcT|5RZf(^2NL)sji%CwN|!v>bxIvmpf16?uer;4|NRzg*oo~<+ZJ%! z{9&v89q%yRcgs7RZZj@ZU(ZBC_S<@g-AJnjpQn4_JF#%kuy>(9JUY;u2e%6VvRDk= zfhFK=8yJ|zg9uhf{-!uU5Xq$Xg+n)_9l@~IY5|nZPUXdHZ9=@y@Lgub#`=Y&WV25L zL!IReN935rNy?X>-L>nB#t-my1iQ|&tV2(b&-PKFaj%SZusgfu%0;+k z5Tkt)_gJrqy+nV^iyJXBRvjb@n*1G38NF4uj}SX{%^jmVA4OQ|uR8C4(_L@8^LO9y zI_tf+q6fc8+lybda_w-TUEx#${Xqm?@j12j_U$%)p!4W_ulhnbr*8_ zxhllS2P`?IoL*En%U-PeuAQ~%oE3>UrgN9NSywh`L)Z!Raj(Yexju*KmYq6-mt#e0 zpLv3syVS*XHTD+`511`@(oT01CK(8{=gHLSaRHr93{DUa=6qZ0aG@<2xOQhUUoUO&& z;?)vY z@Tb~Rp&*EU8iF(_ae$k^$CBK(_BLK1)s{?e+F2rjg%{sye?mfhdi=Oo11*k0h@1%6 zXJXiA9wYiAD>_v_fxpY}VCfGuA5Eja)JG$;iqvDc{7k&cP4+CCKc6JD=7K_w;js$~ zF0!AGzJ=?zH_F-xQL#vRt(qdVES7-I&2(gNB1AV7$d`)n=))kXZm{B zshsdOY0ev%J?EW!uZB6FuC^L(z;(Rpl({m7VbNAKr&hYVjP9!u;FX}*5GC|EP|bs? zr^=8h`EQXUDXG1=1C9(w2I-ac`{Wrz%DckGz1_Vz}j-Q7EPzPhcW zwVkX+;Ye3$*RI!03=9;3d<%u!3xflrAJ4kBMc)Y(- zO%L|AkR&RY&1c`_a3qtZgP=Ifr;>w1)#}XQJ#&?QtJ#~W43Lcc=-k{u1y8fy{-KeH z@qxk4u4vS1b!W%iZ_6Ony@`S?(FE_Vh%e^y$t{-tA0hS7$7K2p%Q0 z2f)wxBx|;{4OC8eClQROm4yw^7Gw?8>J`wDs;+F0p-qbei7N6kR^1bgr$$JRt^}Vk zIjcTBq>TZiuCpO$1_tB~l5~o@AOs2LkW^g&k3KmuKGxFbgl!FIeFXi3q$NWw@gbGE zLAf41E85B#faX$$;<}(Or(Pu$wv#(|c!W4U%=`Se8Sd|lqvRf~*?AYN^X}*v?b&Xse zEzR>8nL22k#aD&(FrT%^`K&PD%Z9Sra|g-U@%RZXe?aJ127bMe_H_ zJFHpwcUH&O-*y5Y_y%vulXv5w8OVQ%PdyXiFk?K=&-Z@tqvJbU71 z(=8~?Q36yw;oPf?V(kX0v-S`?f z(3xsSyYW`Zg6yy$|C?YEhNoY3G!4Q_OH{?u1?lJ8uIBQi3xAd&e3b<21tJ2kep%FP;BoK^t$K1NLhX=r0zSuR;a*lY}#DYx4zixv6}3k-!|%wx8-_+4|I zrS4)Vm`;y>&hN(7u`8SLdDi3&^XW92k$h_=+uEAWt{oa^%_LL(m6@5@>FKGy!DO-$ zi}v)4j17*Bjl!p5iCDSJEgPy&-`VK2fLxjT*4%ng~ol+9yJI(sIjJgHQsE13x3 zgX8qK`vSdt?_~TTfX){1Ar2AS&@^9SEsbtID_;fuY^~s+F7imlTmXH zG!UIB38vF|@*_wA-`3T9556Od*klE$xCFc~U_p)B-SNkjz%qZolU9r7*3?R9RY!kFZ~SOjBOp z4y!fZ1)!TH5M}95ap&R*uytit18;_uav*-hPh{Uq&_ufhqCqbr{7c= zhHg0cu6G@Lc)@&dph7YxAw{Rp8y^xyxR=WBR6Q2~d>$QplLBi=3@%Tr8crOdo5AqBRgFrs2 zxd)_PbHx~cb;qlfK>g+FPFgEQ{4+SETNhxBvhOffpEbT0iDp0?Rdl(Swpa9_?Nwt{ z_8lCkE{t0TMSA-Nj3AJ^%l%TCkgL7r!q^%0(XuRcZd513N_4l{3z7gDGH~q&wFT{6 zhA-fH5vxaLo)fV@F9_zAlSw^pKUQC8f0{)pVf77QCO5uxi2B5RM?t1gR^)noo2<49 zX(NQK&ILxB03^(HkhP~@4ji%ZzWYA)DK)-{*E9SjR{CM_KD~`D9N@4ZhwWidlL6&Hu!VjtY`W}S8y ziHw^UgTP=fc5rYzmlYqUlcXOvW|L)D8`4R#l9;(VIhGwd_aVK{FFS(ngR;NZq?*bL z?$19MK8Hu=wmQl@%WEJL@e7p_6u!VAZ32j&E<$O*{nQ``=DJ?Yb#&ND@I$M?!x}$hP)o>;ygLvcO;144({#vaRiV7jGv`xkqQY;6sBFFGTpm&UEef`8-#zz<3 z&F#{h#p0HuqJX2edaOOwLv!=*s=G0f%1(;TtW5 z?*58t%PxM&##3EoN5`b!K`wANmN1jKvwOJH^lL9`&;Wsv31rtiK62Sgi9p2#SqShgeI1Q8+(z+tN>aj=pA{d_@ zBRcefA#~)$5p6)rr(P=fyv`@P9Wg-l@u)LALHyrX?-N&b-v73@Eo>jxo*C1~6J$?n zho(cG!Y|__;4m8At(kkbZeB;EUDfcX3#SZ!fp<^s*R_|77U&Vh!(~GqLEj~*;SS(E zF3hz3yhtz$R$YbmqH#c*rq+4ZK!NE(qp$#W0IbmW+gqv)_wbp+qEN_*m4N+*~LMO09y3G9*Fz&Vav2r!bl`D%|*?YQ% z=slY@8#M5D6b(PUb(=xkr|qp1bPfzP`r{VaHxF^AzNe39vnVBW*bvgc>p?jZ=St_p zXLX}X62MDuH>plgih86Nbw14xa%P^09K@PNhy10VtY3DQBiZUA8J1Hs#YtyLlzu!| zN+mI+dLl#VOc%IPK3qq<&I0*kz)rIIy}r?8(qdgNd+V-2>@L(zrXXtjUQ z%rjGK@;SY)=iqT8wK45kZ1U4&(%!Gj0yFewBdJX^RNadHGU*!nI~ic=2JuD=4v$Ts zYsw?k&qsG|tP2NFSB?QeB4gSkQl?eh(gmVVIax{mCb=K~&gza)DzxjfALbRQdX`LF z89$nQ{nu!XKx?^ZmmU09R%@UIXDd5tVI7vWB!2B0C7VeqVVMdC0w%kQ9!S16Ad&&0 zHrh>4$?vyYJx!@&3GZtU_}wPBpryrTGh0SyMhop|J-xZ?;0(~>KD@syUd)WN6b@S! zPdOF{`@`++s51&L;U!O+L!ZX@m;KYDEssL)dRy4-Afc_> z<+i%y{551%VsWD{@q1Nuw ztZ?^+dT@n9%Z^@$`-}CE2DOLGWxI+Iru2#Dak^a|$?b6k~Pmgx~!*q*_sBoLTLANf$ka|gTc&4Hxn|}Pu8O`N!$)?tSM>Iiium2`4C)d z;DM_lYuy^A&(h~J^Dd9RlNU4wFNe7IT-aXByu3)`k0}~UJlQ9jXpBxwpAnoLWwb%P zu&M13xoTe{@nD6R*dQgHPHns1k!3dOs(szgt(!N_n740OZLgkpmT1Fy=MW3i2HmUn zo!hqpFl`1IufOKTgDeJ=Ml-hinVOhX0>50x>ZisPaoAR zO_nn7n+okli;cA4daOHwDk3Y(ougQWKglr;Y}4>yac;+bi0Nn-P3zo2C}rR2!>=+} z@zu4}mxQ`547(?Z!lf7_!P}Ihz7qGPKZtY;l9DO^8X-xfn1RDIMI_a&T~eoL9s$Z- zyK3Rrz^^(zu5Y$Qd}fouf6ZS4HZ)`y?hJKK#S<}?*IG(b*m{X>@Og0+A%B3+-#X&O z7j~qh9FMwUzr8PQb9;TDEf{uv{0Y+A6=HFaPssjcGPe+;l>76~b_N1QYwY<$6_zAK z_n2^j5C13jHCHx0q0J2^)8y5*{yJc4ae7-@w|tqPb0xn4p3)~h+>u4Gq4w4aDrfc$ zb&acBIua*fhXYrgsZ7p@dn~I@$?;)d*S{L}d<|{1sFqTg+&BR?g0?#}pcj?WXjjd;A)XZc-?EzqiGKoS>Lzk zoMEqL)mgT^q$u08X@B17aQCyOisbymT?B{X+~WnQ&gu&!9AU4270I`E%pKUVqp#1h z3*AC7XtTSE{&1*2?{N{t%~+B8?|YGD2U!5Q63;w@YX?Bkt1IQOl=U)uud&;^Q!i zRZ(t6Q4SpO)lp74@yn@H7j{=dyYDuOyvrwDv)t>J@kDx`dx$IDLCzq6oNyo*^q|@nU=rk+#H?Rtq-_wXcfl~9) zJzG~rFS`sB+^M-XPOX+&OC^W%iEk?*UZ;+9QYZSumR9Q_i!W9>Gzt{<$Ul)cPO z9`CF)6Z69PvSD$Za-eo7FW_Yw$w!*G+9eZdpJ?V}%QBeXyU4{pBX#*_>)XiAa&CRA zHYu=*c)1a*r(B*dT5HAFJEWqbIp?x;-BDBSISH2K%z6$-x@5g4n%U6u>fOwOS`yMn zL$CY)6+RJ{tZ+(aAHQd z02a6AY$Y&}dj~_{1V;u2jm}}5-wDl2RAkHrz^=l@IxXAeVorWnhKtIDLdJI8#X|Y= zHhc1_paQoxdCj)1z0-yEJ^S9W@GVruN009P73u8THkZ2negajJ`L=G5pkDuakTpCnj@wH{M>E7keeGRE}=r?d0fQzZeUQ z$0QYGwo>Lll6RgrOLx_g>zvjNg$>im%eYD1tKvdAyIBs%=t&`}pt#P93e%g;WiFA% zTcT^7HzxX``Mp1UF|EvUlYifHe$5P4P@>9ZPH^U%Ry6WE>pia#6Q{KC7t0DhA78(n zsi?Y|+~7=3>*eG$vsw1IS1eEX620?fBn|&c^F()98N{`>EY5)E(0{jhuaZQUP5-KW zc8lza@lCrW6If+4>{iXN?UikYrRwd=XowFq&%Bq}5Y1EaVp}|<#=>Ute$^KC{ANkv zX!eqWfewxRUWi#b^eWrJDK^Y*HWFyzo*~fUl^hkiZ`Wls!?Z3BTxJ7EL7`*wrbXJ{ zrf7dmJhswtu;D7%N@C{Ns%dW*b9>}Hj>-@hvEu-2{T1X&7n%$%qea>-HdS0+JH7Ax z6Nnitr#^q&tlDj_xUL?byMj@Jn3LUzn6sN4HM$Wi_Ay1mz3H$!lU<{GRrYhOBTjVp8x1m(pSjk<>~(gv^5vI6nS()FAB; zeM{UeF}Fp_Hy@gM=#i;M-m~_h_fBcsA71;&!_|i$UiZ}-hTE>RxiF}oa zwt=q^CDHa(1CtU=g3Dw)ajCQDiJUK-T?Mo&t!X2=fM9kDIPncFtRCnj*Mr6e*ib%a znA~~e_UZNIo_O4CaajE6o`VCM=5BoT++24V$gS+`&i>mMekwsDtyV|4qkSkI_FEj* z;BU1okg*-b%0a_@J&9yr|GMcd{ln3iFWuAj<{SEZdw1`?vAfurn;p6B;|oW}y0d+S zuAXwL+M2OhTP(KVf>-;<<~O`)`|%MTdi~X60PX+UCgz~W&vxpDev!E6s-zNXf`YKtdbX@8To{!B>#`EnXUJF*~?Zy zI*{Omdvx99kZ!4vv;yKen}woP6R%kuk~xO;KKIc7n=uVf*A*J8jA^*5uH(>YbTc&C zjIHk`oyU!XBwikcpJ(Z+xSRLkJW4#6(u_DgDjSEIXXOe9cQWT7 zo_PeiRc7SX1b^FHGT*Q?2kR+2o8z#+sm7cp8Czq{E!AOlhb)82v9TTl?lF1?LHIWL zgYDXmkjrBa;3{j#94}?YiybrV!(){#EiD0m=fQzoG~soV3(?_eZ7Ud_jE2Mg3wujm zzl$soCR2;uMfwo8drD?E36#65hkD4-SBzkGG)4a=4+2_d9q71GhuMbfVjxIO$stA7 zbU@6+;rh&KlevIM)o7K&*9ii*FFXn2_pnEeW|2%Iuyo7##)gjV49>=66+UYZmT;A8 zb$c+GJd}<|nSMh9K2>eyeb{V4_aKMpCfx?1ii;%E4P_SdEZ&8V=wD3qx(NE~E<{2O z8+byNNFd~K6%%CMiN`|WQcra^L4>isSTvYhQ!bNy{-uRG`g|4}PL{KCU8Qxyu|(eI z>+af}OeK9ehslHsd@|YFGdSAO5eG@Vqhp|y&jv}5LXooB(^Nqb|_%d8>uee4$ zpy))@wwy7eUV^Htb~m1y{Dp0N@s7_JX0_~~p>4}nV{}8FL$;lAC`Na8!vo{{K@jLa zy=S{&cioL!Npx{3;4R{Zo!PEgMzu(9P4_#JURc%KV3q5=-y?wAF{_l`f>Nx>D_S_eFJASF|;!&#%jBMVF<|agV9? zeYN;q@$SnTfzfLjf%DPZPONMk(!iux0SZTxf9#;+CsMSS|Q+PG?am7E>L9Y>IGoY&r95ZAnhv3dP1O=G%hKk>%f zZ+rdC<~wd_Qf6IP)|q$iqsPoU!B=T&{(aIX_l=wW zv1T~E6?s>}(d&s1Pq*?_56x!U&_X;WY=G1v{bYnT|VXm^ppep5P~Qsx%Y4G_nU4DPE;CDM)K#bC~Y*w3no6nn%c|9#A?S@@p4{OL&z~y&E10i?v zV8v@QwWLz(+|zzIu%(!=WLwh?(AjzhZ18>VfI@^3S1VCR$zEo%{ z04CoCz+PB_fMyDgWZ6$H#GJ{CC^jt`<^x;yJ?F@K0GAc9Xk(v-p4- zH|}H_M@Iw6LW$@wK}dh3*k2)MphYCd_$4|han*hPEbsvQFgU~y(Tt4ni?5Xtey7Vo zpVp(c)#5M$h3jB+N(+|JoMYTV{Z?M2zC-~yL)ujf#vomQwNJ4$-*mb`!9ZXt&k$$No_0=Q6j1k%QmpH~v1T8i)nYkkA&T`jOPk&o9nMkI4bAEeE z%Wpg&9cEmiuy?B5^FYi?t_(5-8t(Uamgu;(*SEH2OUZygRZbQB!Cc#jJLmN~T$NNT znXQz&6A6+hw?w(#D-%~4eU|ZUU0nwk7kZ4Fb7;S{kqu?Fw6EHvONNL~ZkS81mVmr}H^2uC5(sFD!_!s%d_9VS$@D8hoFh;nOUb9|EGaf$!tc+?oOK9xV!l zDLg-vhW?G&6%9Se2WKZ&Vex2s5_HAhgPxa)6E>3pK)WkqcG!P+k-suUNE;aMo>lLj6MJ=uaOt~O zZKNTAXU1|(^1-EQkfR2v3)kQrMz^#M9ZNJ)=dHsM{$!}VKpo14=b(RF%$=_eWJ8WG zoBmZhV0&?sC8k9$T!T8JSo-FDbp~+Wch>D`^>=-YcP)T;zx%vz8_n^y^U(_M%FF08 z3{T8&J({Z>aRIaT%R9`a*T75b(RJYgbiS%-+Jy&*!bghL{vCY#d9bB6)@?Tq*El$D zatR@QeHkKFTfQ<2e0{2qURJfQpRVE|VV>-3T1`wdWE^L;=aFIhn7*7)GLo=#X=E#B zu>r0+NgS2sX0T$9k>OlrKxE`Z8jFF3Ai5Y~Mz*b@MG8iUt=Jn>%%8qK(D5<#g#ji` zPm9f9jM@z8@k(cBD^TjG)IeqZYbGcCp;W5-X#e5%Qb)0O>-g<`&zM_?{c9%!GJk%5 zF5?CE+pyLbvbactoHca;I+H7v`>SIU>D;c}AKEZE)ltm%r+R{JXUx?zo>}s0tE}gc2jABCt#iLH~?Lvvg?1MKUY=(wzD#r-8LnF}D%V^m!pYFzVq4 za3^V2@C{4UP?hYwexNRCTEJYF0_g=^gtxeysb7ZN&dz>JS^fVSaXL+z^yXrFA@*F* zZE>x=sP^pkg%`-~V~vm(HKO@Pjo|$Jrk~hLpN}-^Lmlm*NXMp#$!K#uaDL4gc-l(F zk$v>b;8Dvcy*gD}fgaOHvk*;7s(QR27I$=Yo51XddfFsgSD|^I#wjOfO1GWut7lqd zN{X`_{!COd#BiOeNbn?{v0#YRd#fisSi!x$qrK49>aYO)HbN3_$W=Gs46D>jy38)e zwob*Afj+7)g=Ie-T4K)xxpI-@(N;psk>;sug|u8Ur^z*^ z?2pf>{y5P<6u1N}>tTBworo>@YcM$A2FRD@_a%K9U@j?cPmC$nPVr+9|6n^LV zStm9Ho#u}zn#%~vyO%}vC1H~*CVErMLCy=(KR$S(K14gTO5|EzOLbs3DcO? zt=;g?E!Ub8*bpCEm5mL2lKe&GL)35&+lri{i@?I|F@+zjYXer) zC8Z=|m)LGzY;`em_VltEX~fN4qDRh0*Mi7AAKd}!<15r7nLzr0kTy@@a9EuVpqiNS^=u@o0=Jd2bu+uS{l7@EhM3tK;OIcb_wrR=WMez-92*(vFPZJEr(0Mc>u^u{l6uiJtm{+jPQ3ufOR-xw z0u%eqMMbU9iI&D04#n1VcZb8FP*-X6X#THrcDo5{WL)#@a5@6MNWa&!pI;r%1Ouh_ z<{TDVJE1yj$A(98`5jt}^dI)zZ)aMaZXli_hJxMibGrry_dVlvXIflptjoUk7=2Af znBAbuL$bHcv@~}&XKui7wztg_Q6@*biYQxNZ)5Z}g^&d6_M#KKb*=)(?VE@N*%|t>`B^*iH#y+a>66;EsWR=6nN= z(|Az3HnBa<_DdlCIPQ6bYC}84 z_io;!%sD;Wv-dw4MF-jVth^+|z|$nDd|Zg37fFu%Ss_Mv#>kgQ{O4ukxufilS=qRL z{A)nwaor^KSdLi!S z^X`8W;-23V;w@Zz@7+SY_5DKJ$M&{KA@09Zh_}C8h<9+!e|=ntcRnD*1CI&u2R!@T z-0Q)w3Gp7D^KejzM_Pq=FXzASheEvnCLuoH6yj0t^BCuRko`Xy6yotGh4>Kf^2CEe zeE3!&p5$I1;ocwP`j2z`Pr1i`=h(@;LOc~^YepRcfQMFC%GL*!e_L4BOrHn6%yNp$EEb{!Gk-w?mUlhT{KF1S0f0}px7d|tF z^~AeF9rk6NV_fUko)r;3Bl45uvMvd}d$kbTw%XsYACmjf#>>8}r#wH%mgSlh?M!#km+6+MBZrF;kI2F)e?d)c6OZGrD*LpNFHwuu4u zLwwG%?c#SyH@9{bw4wFcrw+AWvLDgjMjdD?o*OcJO}?|b=K$w~w3n#&OXP@sjQh}* z@)?GI<$cTQ9ue6l+V6-Sbu5Er`TgXplE?m*w)p{d{tfl|Dfc{9`+*@z{r{f2!%(#? zs@-FTU+~;t)PBvUZ0`}?!G6At_cU?5SGBQh^ZKURShjP0;~X$R8tF@o+DjJ}usJt3YDzj)bE8i!-w7asLr2`kXs>s-=`TWiA~mcYm$i?ej;*%H3N}t=n~QGLhxj)ACW4 z_EY+QQ7U)mTThAnbg%r_docUM#BUBoCQli=PKlWwwNviyl_#9i^3(bc+KhfGXZ$ch zk*{A-?&izC(XT$E)ePbf?mJaYoyw(8*)ykFNq|9*HGEdI5Z3(?^b!k<4*AJgsIZ|R zl9fB(-}=3~_J~%|nu~G$*O37}Ca^H`2Zd~F(V)E@Fe9t6Pg14!TFRSL`8rCQu+{zt zrMvbAlx?-|P_D223FU^`%f0rufYVYI7zS^fK_cLB>;y~?L%7eAfP@b&)T)5Po zT|(a3qxL;&KgXTFA#$Rl_I=7q?R%6%wU1H`*S<|T!udHds><=&+u1)*dlTis+Pf*` z+vd2>dnixVeo8sdRe3U2{2gVId*(UkS?tsK+7Bt)RasPJC+8GISM7Pq?%Fpg%UHAv zqDPgz>YRbv-*RN0JGXP6uOO7s;sE;BxM)pe37!P z_Scjhym=Sr$g-l!1Kg(z`n*7Sk|QPB@hgi$5@ zq#Ui?PdQ$D9pwSuq0Bkoqnxk(6Zz%j`}9zkFHy>;_o%1$sHgX+JNNSb@IK!E2b6BL z@1bRTdH+vPCRN!6wR^e4k0|?8SyAOCRi32mqYuckU6n;uc2bu<`oMQ6yJ@LD`oIq< zdsNx0&Kcl|eZ1|@h}U${-}-4&S?1{TIHW?!e(3N%Mxs1tfTw?-vZBhHYNyyg$^Ia3 z`vPT>dk)fzOjo?-t{^$sV=T0hD=f0q`kUZ5;Nu~FXnzf-QSJxRF%el^NFKS8;Po}=x?LE?HS5_>YM}W*MsW#5!z@}z1tYi`T<&ileHg%&S8T}$9#MAe2{99P%B@K^4jC-+T&dNEppo1c>fc;&ub~=JD=cvTKtt_uS0ewzz=g(6qm*)neN@%uQB{{msmt3rB4^k~smr@5WnCUs zGwh@E8F>de!=8k~|3oP{>Pb<6@10b9^`ww|^`ww|^`zpfCly~ksrc$i#aB;)=Pl3a zhZmh>PJEtnSlvPL&v|I|Jf-A#^UzA}OO8iEB{&|rj#ZiC+IedCZA!`a=Bf2RQ%cSc z&YvnJ_akkP+TY5X&qK){Q|?hu*$Y3JS6p!(TFJXfuJ|nV`W~h1rC(?NamoaD{yIl~ zE|AW->Us8`s|kcuwFy!GM53z8Siiv&F^J4ArO@WT@2evmeaAm$wp{I*{NKo9tVWjp 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 zcmbTf37i~PeJ)z3s=BJX_Uf*s_kHh~>7MD?7fsJ-v8>&)Ebq%m8cAa@ zcJpf)61oAuj~>St!n*?>$MGz?)8iRj^A%NwQ$cF;;_5%y#I#lv!@qMExzhwUwWKjg0I2&etl+n<=kt2 zZ{f%I?I#Q)K6d8p;+a2w;JSat{oac2EQZCu_;ckVGvt5zjeoz%FoY!>;Yol9L?l5H zA`+2_LR6v=ofyOFKTtoJgYsmp}9XUv@CpVBA$xY;DatpbY+(vFEcaQ~gh#V$I$RasPj*;VJ ziQGv}kdx#TIZc+y8FCjnOIFA^a-Q5xUPSI8_mUTr`^f#|0rC>^Qt~qLa`FoDAbBNu z6?rvzh`fe8OkPV~M_y0fKpr7)ByS>*k~fpLkhhYzk++j~kav>bBEL=EMIIyXChsBd zC6AN$k@u4ikPng%kq?uPkdKm&k&ly4kWZ4|Ay1G`ktfMh?>%#T@w9+@EG-`itejj}InKX$@$53Ue0q^P_oC&%x#MRS7x|;h z=g;y-m+oHVR+jDwtSsKWcsj61Z_1xuI=zT{om@U0T3I}`!}I@J#?tSkyg zPcENbI<52=N52a$o;V(6BbV&0b7N|Q}iw1 z4Lfx1?81@7QwwKL2rJ&hw6%wMm$J)?p&O4ZEuLM(xFug2k1W6FG*4wNNag4x1TA>@ z{8`A^;d{Az7f*v)%O@63gHqzk;$h0_VDAQ@!^>yx_3kS#pFMIEVnU_Ho8QUBQ_CJx zrIX9YmJTnRJiUBQI=gsmi9TcTh;VA*Fi0UBS%e%xtRc9#`230t(Ofz2N$pwH6VcvL zVBzrjbBpY$^Hc{G!22`DHD9{v(CIzJ!;43jphGf5ymys?ah(EJ&tqDP9xc3qd1IDv z9z^*pE*A0e_`=Df{v*3zu7Cx;Y*PvebDq2jbKW@Qxpfu`b3Whb6bO( zyF(JT`WJ8YFW$O-acFDrD&cDXoU8qFuHJCY)xC4Lt>=yld%VX9d;Qb*`ls(*KV8`C zk7sZ1s^F>fC(kXNIeD+J*V8WHI{(t^{7bJ}zch4R?;7Do@5jJ_yBCE8|JsH1YlQ{>u?xLh1Qt9pEGz~O_s9Tl(~B4Vix<}~4uN&PPzp=_IZOUI zUA?EO6k6(?!!3dgr+qRk`=>Aar!TMHQdsuKv)sE%II?v2(vd}B*(btz|HAYBh3D5V z44q%Q;$H7Z*h&x~bZ_r#&kq~SpE4AU&_Ac}*Z)iy5l8-K7N5^`KXZ)C+}HigU)kMF1xg_p`{1lV5M{X>a!C!?$Mpv-_99=j`>zm(DHXXmDbpjkmdzC$syh zM_9@3UtC!{d-vjz0ero(xR@O{ch9-((WR4%*`v#6vrwZ`E8eFgQ1Y`&ht5-5k%ib~ z&#x>FY+g8fWa;S9{fo!W!_93uu&=-SP512}b%Qv6aG=%d{o=WpBiTduW)IwZX0d+@ zjMlN!7-D;%#cXDlnKR72%volMImR3ZrL)Y<%wgtMy!Yepe*AunS;X&I=6d|TgfkYq zzYQ`Ic(uDnbIeJ+=oP&?t$0U^%!+sY-T3ziGtfPoen;;!fcxF!J@qKAJL!FQ6wjp3 zqfa^uIiUCHeRl+Bt>70r-b2iJd|JZUr*Xyt{yl>C^WO6YFtY`mMbAHq*M8jV7=AnH zeY=G@!0f~PUU<$6j}CfIUs=2VAjaCl>xE}+#xoXiPdZlm^h2O!7I(iFR9(aqw%}iS zk7F2}PsO%(A1d!`S4u4^O%VM!{tEPm|L;G9<@kUg21AmpsG4q=;fQ5BZZsB8Bva{3 zHkU6HOXW(nR&Vt64>VhYL+#;_(XsJ~$*Jj?O|zZ3`ORClZo6vx)jM|X+P!D*zH9bh zd*Hf**WYmCO*h|i>utB=;dd+?I(%gD=&|EVchaGpIC<*y@|nBNuADo6H^Try-uvSF z?tkDVFMZj|U-2OG%2&Pmq1Qb8+Sk4Q4UfF>O^?3$EpL6>+u!ld-}>!$J@)SRy!Y|< zz5fFr{LqI#^3jid{1c!2ohLr^_*Z=w(3{M`zQ_G+u&j`#*m@7cP3&5>!X8)A^?^!Dw&!)5a zY&qM?PUiCYa6WeB3eL|mj{_&)v36c08_g!OS?|1w-g#I4j!x+R@aIn#pWL^d9^bqqJ=3(Zw z%V3@nnGY}@ zWIn`vnE43vQRZXJ$C*zspJaZAd4l;A^Ca^W^J(S+^E7jjxx`#%KEr&L`CaDsVA=kF z`9tP&%pWnIXa1P^0`n)#7hzey#C(}~hWRt*S?14~uP}eXe3ki2=4;GfF<)o?n)wEE zCv&%_@h^h>9)%pAVeVp1Ktt|;&YgxNAA!U!!xkKa{8FuAFb7C2r8fD0$j>3QA~XM_ zlp!672@r-O;1|=t0So(0s4O=IpX4)4?`K6|nZ{F$GJlyDS&=~GXWi*~tCBNw5i@5J z^7BWMckf#@e&oa3L=WqL3mP79VWOb=pH?qa(wW@)KG-6yk@olECq1 zIPB`WrpBW>wChvfxO&Ilix5w~IVQc6j3}FjF2aR4yQK zLBi-HDrz8yW;t$xXEJ$~iNF2LC`b}{YJM9V5&~sQQ`71_`>xum83EDKG*y=E@vBc@ ztiqKm3A=Q1-p&Et~g8DVtcGz5esAFTZ`76 zhq6m zxvybr`C7<84wlO? z!z3Y*Tm62ewD-aG4L9(LY}wgNt&xaVO3h!Y_umy!M2Ps@oT6LR=^sV|k}PUw#HuHg zt54)Ixz=DdCqj>BuKbJ(upfr6+6hZ=H}ef&wv}x=IED{Gl($2>JE~Jnt0T{t}GoqR!AIL0?x9ksb* zJ9samQ{?{b}X&xqDXFTb3CWQ!%Yr=?`m8+baT$l zlNe=smSwhXXPNjWDli&hE$%Afw2+kxAEaNzZ@c{n&t&V6 znmuWJ5vqDhc}I~7jecV3_|aQ$x#@-j`}fIv4njFk9b)&S*~Zh$q_}BaM=$ON+oNkz zaC!47RvaO9Uuf{w>Ns~S(|a8`wCDEQ=nW4Z*cYxx*#?2qcJ&X8>GB^O^&JKE5N?;R zzI2TB7)-76=vajc0OhHFSb{#chr0I@#3suPkKy*NLnX#Lk^R!Q5)spJ`?q##g-k3e z%TB%B%;zKFplE#|9xoLu!}VG!lN_*enQDI~8{s9E=QPKPwPsM5XPa-Nd-P~5-Bc5qM>MSd<5gu{qbcy>FQDiqZ2(Ta-9_M z35KDkf_uitlZFsXCP#*^RW_AMMu>;L3$o>8GmnRxo%x*uxkh<$-!-$dqekDz#Pinfm*RvwC*PjoZ9414RB*pqv_kb6y z>I|X!;k~+0j54Fi!s2$PPgPL|!$yX2J$0ppJlK{ZRH3a^X=`Lab!K;%dBU#uPaiQlo|>1q2SG;fl?$q4!-=k!4+0!&b?#cnviG$;h!R zCkbpd?S=!Q9uY&N)UI1bwyz!v1%gC{ke&$fglENkv>=7*t&|bQGgKqpaP#3{C~e50 zpvp<1uqg_Ja2yFL0ZB^hLduq9NpUKMY^C*9N;TzBMUXh6g&KNMv(qt~<5@{jGm@f+ znhSUrU;`v%87h@8fN+=m2XY}k;?x)+BykMK%Ul$C*9F}gLxLfED~Ox|@aF+xl)$U#UPzGN-Xz&9*Wa5CT-W>@(oVdv&> zxD9c{CCo=>GITx{q6*LZ7kP041OE~1P6DE|F*10-Hu1-HP17(I{ST(;x`t86GvvxI zuly^537S6|@a~&nnYH_RULQvHou zrIgDA1sB-IL$AJ9^SqZ2SbfLm(pbcEWj$wB$h+xGyWN>?wqkKAj-2BR5AWVL+;-hN z3_YEy4X0C*B#Noz^n+vvtWLFFD2|R!ho9Mdne=5%kk}B7OQOMET;fHI473&TPj_;#Fgg(>RO>u?j@h{p&y1S| z(WXPUO_<8_#F^KE!Qj)Wn4WXMG(+Sxt;p`UNs~jl)mp!*A;Q8KKw|P8i~%{V?ih$k zR2!&-t#6d~VCaQoxL8~#Foxg(f(Du8y0)taB)UG1VuKz*b;$C=BTC$iWPg^OYVC(} zcHMfdUCR|>NkBEnEffa((%DEjrl;VN73E=}A*hJh?aQz9e@xzG|m=McmA@p*^}DgJ=k9>i8L zCwonXE*1J<$SYk#zHtf^`9NN}z}bV&UcBmk>#ifAcSc8Y!>&^ZyH283>x&n;$W6n8 zgY|~vzVv3yvsis}$QZqCB$=$&t|dFvNTb&1)GSqvXA2WLWUJ9X`l{i{Yx*-OLwz5A zB9m!M)oby6&>01MqxeH&ROUu+rd?~u%L=R^KhP`1MA!6rzTWN}Bfoxm-Qxe;A^yr>{VL!M!zv-hN-9-LyY4sB#=$--HFR@P{ zD&6IcMrLw(whU@#sSbx)9-W)knn=M$@St|oqxJ9S+0ytp{87c3v2*x;P*A}>X5^g>V&N-NjWG7k$_<(u~sU!c1~|9 z+LC13mCDdar}3;T6oV>q49}b-EE_1fZmy(9jgnnHo)J z(j{4*tk?26gsKO%YlB8W_%SaCJTIxDDksWi@F@qne1m-g@#Hq<4v#XrG_;iiJ$Pg> zqKK-2GA!8mNp_kK!blB6|Za7SJ*AIsQvo)O+`JRt)$08#+opn9)K1Dt`_t&d$&|S;4E!i{^&xHv4 zh|xFJ3}<D1v72ViVxgA3&cbn7zyb^Qiya9rUr$UA`BBVYXtMIw*-q^S&{;jT)9a z@L6pigbp>wIVO6bpK?&yw|{ca_HA3HgS+P0#wEmE+<`mRd3kXgMBj%a-dt|NVWI1+ z?0t9KhA0i!@7l4QrVge}zQ8ud;I&@JQmPIfR`e5j8!T=&aLf2$f2<2LX|BcRu*Y^P z7B67`#N_xjBmI5xm>$qVlB5A4%I$hOWf;*|xilOxWhWm^7`m#2!|`GxiI0z6mB~s0 zo=az&Gg)xev2Pg9rG-#XPd2l$sG+@ecfDa*i0Ny2+0hhPBVs_RSg~j(lNrco4c&+B z$z-9F$RIe%=Hk&z`hmez(p6N+7|msC(O5K#1kdqOG2eE>VJ)euu~_a_6}pwW@=xSh z@Ql_oPI(;DWSwOyv+UeF8&#{|jK;ac6Z{xl4igzzJ z2#$DWg3V=$iOw*>Rzp*@WU^Kr+tg~s6G*_ua=BW*T1|6$E<4}(qv|lo8+Pqqh1Jr!ss6489Al+Q*7mf9z3 z5zuWPFyZIOvlJ6{y}l-C@id==>Jb3MH8n#bIS5)X-JB;54IKJ5yA}BjyvY5=@xxIA z_|bJtB$HjB$lho2tYJD1J*K#1^|v=dONgpBz7^&>-uUXw4v()g#1Z*#e{e`fGFA!4 zIp@C~>7@!0BP8sbOVvt%i~niB7LCQd6r}5!#&saLaqRnMRY{8`GNDu~fh0H*RuLE3 z#uv`w&M97kOdFQ$CQyqiCiS4GAQE8`SN@%ivB*I)Id3Kk3y~+ZYybn$HD_FK_9;Nu zRzEuuumfyt^&t7!Do0jlKL7d6Kb-#I{3R;u%!?5FJP1oAG9$jMd)}L25&HrQbngY) zz$|iz1YnSGfN)zN3LijNP|DdkxbqR#T@}c$=-=PnOq9)=VJ&cZd~KZl{y1?H;@x^J z%6JMY@)RFLQYpj;2)Gfmz%IF^k&+#V6SBI>l8?=7#eg?|9{0xpwp{sl@*dQxIQw5oHVQ|>Rfby3^xRZV(AqRogR1vHIMr}J?)YMO}Hs=UCb>E{Y^&<)tN6R4_v%;$!r z8OIQVf>MVmrLG~&b&#NIb2u>BdSig^CyCaL=Wt5xEWT4lU&b;MQ=sfiW*`|I>Ew&= zx%+sj^ZKeu!<+bF@-^$>&)EIjwyBX$q|7xE*sY(ZSJmmULXF48lY8=y4T>d&Nk*-9XS_7^!r% z!m^z$lA&nnMyZfUprAv9VAvW+6$^GKDx<(P)>n%vf>12&tThIQcF%7f8jQxos6BY> z$(!z5{jOas#2P{nUJ-dL(-4I1F-b|KMhB|(wi7lTE5_SWA$K$qDHR7>rK-&N#fvf11;d(Ect;o&nAWUiG+ zbnF81@X@Gk^`#tl40>h2B7cE>A8bOlSI<%)4FM9BVN;+~F4{MlxI=@;OX+Nelg)L0 zcNAWXxDajI;Q}7paRCYs8d0(+Cw8$_3T>WVc=>dis7a_fWiBECO6pmXqs%N+U`(iA zBLy*1I2E_7#22%Xh%iWm;m{}&epxeYtX_$jD?d0tYevGz5V`sL?u+Ur)(UHSh28nc z>OYT-!lI$}#_k8+%Q7SIbVux`_h3(fM9ZR9G>N_Erx~ z;;o}HV}zmY1H3Xi2-^vT+B`LZ;zS`R#BgKe6Sz@)h|pNOVCTAM05+#*cXGBDfp`dE zir}*^tqtC$w#T*_gd&2GyBw!Yw(J`pPZ>e+qpxzE$%#8w|Gr{dLGdUoyJT8>Mo{MJ zHwRTC;%1J{&3`7ARKy@#Uv2g&$YR?S+qF}#uEy>iD3@Hv5`$qgqzM6%$?j>V(js3; zCU1BP#|9<6RDhk2p;mDp;^Vn)e5|r=f@7vTFcq;)K4Bp{Otsc^D4smfYVvw+r6c9~ zhB>j`LPo};7~Wbw%JdN`CSCarQ?OT2f~Fnufo6MjbbLIH?0X|? z9e(KzS2b*v7i8H?jg3tsY|iHlSF+{w)_F5K)6VIZS&HUd1a*q?y1_!xFf~LLYO)?a zI#r#krgEi`p>}p6lZiy?_0IQF$bj%TC8QvNaX2;fCS5KaBAyvuaf)G*Vuuevs z!~M;A##A-bHziJ>#p}e#!oKCzf38F%Ilv#2CFiV&4Iw{hZD^pSg4;jQJVr3k0)(RGZLGLaYHJ&iM= ze?zn|NsCiR0{g$ACmjU$6cxX;E1vou356o87!txEHr0=ywmcH;msLG7Y6{{Tb(OG= z>Vza-a$Qjh*>?L;Ho!;TIi5(!vJmH=|DPjalWnbz6~bWwg2Y-MpG&0!JhJ8@R1Dor z0W=hh{3DD=Q2aX+%gm4Bx`~8L+1;;wk6FEii56o*P6~ zW{4}S6WnHXpjy6TPXLSB$cI&LEbF!5G9%%Jd3}fQq@C4K0NXD8pLc@*c;@$ zA_fAgE=O2i4zcN+7B>d^Tf6g?B#q^V+LPmxo3`&x1XZPfwQSowZ$)x^NDLV1;YvNz z=K`R}GB8N0*{VN284a=@P6v=rB3ytE5JVU(E6LgRuD)8ey}W-glT%eEYD!D~Y++zE$H;;AcNbOD$uHy_SFVuTP=}Mnco1EFii`A)VuA62Z z^ZhBs6*DBahVZ&b1K*jDJy5*sUa1zRNX}VFHx(Dwvz zP+XnKXs{|4{^ZEl^HvxYJJzx=*Z^-vXpSw2eAf!bT4uKS`V_+wAEr#AUYS;6(pUq; zBvKnWdWj7q1!wryguX@~t}K^8EyA9h8@Ym09VnB6Dh7r~#P_eBx$tzP4QwUm?B%7#ZVC;Z{>^Cs> zgWaeSwUly&L&_!!k%f;ybi_!pILGWoB_4)yg0zOwh%r92fd?Iw-;}Gi?-az))jQC( z=J^h45tUeSbb=QXW8+-hn`$dU#mlF?HwP+oUXjJ;PS;AzkTF_@^@_9WLJa#sx~v=X zY2eGWPX68sT6%o-AELof#6WeDSdlhK7^a}IsGvqSO*VrO(GWC2ZId<%?1~gz{o6l_ zyAj>QW&b_^9K++&F>>?QCbd9-2X~1WL_IYaU7Z-x4a-8RnxK=V@P)|aIc0Td%vFc7ie<;?i)umzxR&zIMkIZrl}1ejc-o`)9X*2K7jkT%Ax z3o_){6Xtx06^o>s&-7g}g*RO{jCijgHV9;;P#&Suu_hZw95C2r6U}HO9?t{~Q8jkW z=_-2v&@R+o{fDA$iXwpT*pMo6y!AvA^-(?ml#EC(LVi48n2?cRI4lbO9Jt#dBXi94 zzU|TsHbPjW20cARX0VE;C=we(RwY_rPb5JC&<~Qy!*HR2EEn)YB;Qw}$#Q>wZ7Ct))v6H^Xw+^=Z8CTMDs z9wDx&X^IjIg-}S)xsV+E@^e4Wi$eauPUP8kpacn^3`m@t3|&lWJ~ zBg8=nrT}?|RA~d}|C0ejecd7Jm9vhV%2qis1FiI}!&7*p76Oz3uJFQ0>RDmV$ynF6 z`To=9(3nKE>=a56sx%AwWJ0D;F_KKkjoYE^r%9iLM{;Z%*;nx>#$lLKY2dgH6`JI~9T+w4?&yt|ijD3A>)7@M9g{ zS{5ot03-o$^OhZwcBKnw{V5=MT&_+|%&z_`042{KgJ=6nE1JvI+b0LiFbA6^2!@gV zA|K#IwT%A2;!F+r#{zEB3q=IjkM2kQMdG0wo^0%4Y5k|hT*bW7lMQJbZK+_Qb*d6- z#DYH=pG5qZ9EB!astP<;M}@TGdNmrz%s^imEn9{LLmjD)vW?5xbTrH-@yY82r^&Pk zQLJuX@x>MdsMr;taDAJ17aA504fJW@;<^-3Z{xqF2n>n`170Xk|5=(m`vZxhuN>;g zVMB}cw~>uhrx$KtXuj|4>u1s?jB2euGkwj$T|4^+R$}qyz}78;t!R`N0!AWLDh+iS z{f-+7ksV_rBmHqKx5(;>&Ko;Z3G3~tL#?rqu`M&328U?jV&C3d56o|=R_pa`+ioZ& zEAC{q5pgn^R=YhyeF~ao;j`ZgnHz>h_9Gg@9AmAKIgTA-heenJcCb9 zkRZA>F3!%dk&P)F6mg@RC^;_Ijccgb^-ZwwDRp1$@P-uS$lUDA)R+*e`!e?%CK*vp z#4|%pcB6i-!AkoD;&)UCD5k83iLD!&W{APDpT0jDi3C$$I8?7itX!$y7+U>%OEpAO z2q3~PKaoL7sPmFR8z7R}WEK@wBb@)r>a!M_GK&Axs@FCTbzCMdKaX$3Xl4_b_`rX+-I46MQNY$wxC@Z!QgCx>Du!^ilz%s@9EI3T}J24K&lmaKq~!j{0jK1bK;^GPz~6 zK3+vX1Meuwuq20fx@ZN9nMSG94(W!xFlwiqR3<;9$0z$+narEF$3*n$q0>IrLU4jU z(70pssWkftx%Oa|&Xp-}h;Pd_pA`cI9v+k*?1>&JZan;ZR6m9ZA|- zO(tYTl%vA)&yt@^#*?Ao^Z(qZ>W~eBBhl1ll*CPUBSCNm zzFyM@MF`nZUJSy5_zPxQfp!F}MSjDkH3b>L#0FSO?oxkfhON|Bc7)#hv;v@1TK%^| z#Ka<$tql$A>o=pT=g8X!r7naBc{=s7)QtO zNB-j5Z++dHUuGxG2;#^=vw!s??jk{2d?ME zsp}4+@0L=f1-HUdWA4AanuNddWR``+N|6od0`8CVI?{Tq#c#e->@G~%z-H&cRBZFi z9~uH1K;I}@5q`XfD54ZFIw;{3^+32yYDw3Tk$6!)n;&Un-AJtZ(`~#`KGGjhIZi;1 zMixVna0597T~}i4)%o&~>wn+~TtMUzlp_*X(b*-~d#>6#HyLvz1u=}J$f_oyP#tW{ zPqgxyYFXJ(&>48~Ofbx&ew7Rc4K*D>EMoC|fc6a9-JDFl>lai3jezM4TS3#HUs2Vt zTL;>aH?~?`eqje8jdIbeo8v`2ljSs@uDw1&FA?eyjJgD!G`aNXdxV~rz53kO zs+vOMcz7`{XKZ%+#c@nE6^gMg7nhMC+|c|8UyqLX4c(6}jVlW|WQ0U&pQk_#b~qYM zzj*bZ$Wzb%s}&EH%2U5Ij3*yvZ!C6mQ-Z@98Z8qiIH+3MTYfXt%5k@gp$*@2cz zu?P-<6XM7&c-vT2_dNsJV+>?>JdwmgCu&4TOe%;#X@wiUzONuDSg=;u_w{$hB&p24 zM+Tc?=$rio`exJc>;GlnELxqsyVA!XtU}({cRyUj<>Vi1{yzJ`=l9UoS-)z7f~>gv z-o*{ZD$gH^=?aHtay-k${w_2ZC@@({vkPcG=%NlhgT(=Mk>UNjCfGJt;BI;3(JdEe zF5$BL>UYVh)wkeTxDRvXLGtq}moWccFEdz9+q=-O;28tl4u`#7<^X&VT^{*ByN&zc zdW$^I{EYo5WT)VbBaS}ob!qVk#sod{8Gwt6v}r<$rfZ+AyfzcmGCA`6>klDC1rb4S{oLhvg2jSBb!kts_YGLjzxLio1d$?&3R;DHOV!Of zkhKSnH^|TkRyZ12*HXJsq`l!#hfX_ z2%+`1fb0a4%!=hI=*#=_HVm}oLEEphpJei=>8^NMrpHM%Rw(jW zk|$AtVi@>}@Bnp*v^GxsYkdVKaN}Ctw$+gfeh>~@OEJF> zVb-Vy8hNxdQStF>uwGt|A~%nQ9`@bhhOxG`Si5RuB$KvnfX!;DCa30sigTc4nG8naMlfVQ|LfT#=E~3|aBLxyT?Du6T(k(+^iWNvx7spYd z=wWnEX1Z~8H}z1_9H*~u{N>xURK8H(S#d(LrX`Z~gsKDuK`)0j&9(BmdZS!MLxe_# zN;6ZfRyO;XJm|RN*>oXa?dwY<(e)zRbE@J*Wiho)FrLjI;lq^|zhSY13q6V)SKIY_Zo-MTL-Ajl?p zr0h29^=N7DX=BNiQ!Lcmsd$Xvd9|AYGTn1kx*`N5o(-tHDyvq1wf3WQGB;N!^PH$8 z68!}iIahLa^J1-B%4E#2Y2@Y;ag$ZCe0y^eVUqF~{5(nuB5F2VEWY~Hm=zwKnT@!S zsMEj|W-RH_+?5NueiXKpX+&ke#D_A8?-$Sf?;n6M~vFjWy6GfLd!7 z>7orv`NdV}-Y7w4PpPMT$8fO^zZHEdUc!|emV9~3L^qbYAX=5KoPmP54INf)PvofD zRYDxE>#0=9t+#9SbjC1dZik07gUB|IYieR(cd=5)X6oUav9uCF0rf`{Da$N0`}(sP z4Y7T(G7!2mmuW}Su~=U*o9H+7d}eILG+aB#szDKSrM>{F^8jRRqPuEDV=NS#STZ`n z`uPL}8BHdHOn#z{xNi+>UJh=oi$bQ*fo+c?>FO&Mz0h_ zMF@U>1Pv#?rSblS?rqdJ8~*jUC>mx^(~*Zo9>xd+?z?YVmw}mr9L5?_(Swc#NynK$ z%EUTw;6yzfM!u6Z44O0uTNOZ?2lm0Fh!PEz;NOGj-vMeAV)3EjeK~m2#FY~H7Gz^T zvw%GrzUkYRpfm;`h0eq1wO!#rmwEI6(oYwnM_g~YF?x;>1)qWFz!J}3t!i`*tK85c zd&h0J+;A{(;3lNEbTp2k)$8P)SVStjThKmsh+NCB%hct2hzR(Z68(?`i&Gjzypt?- zy&hUd>B5}j-r)sh>NY%BVz$!tcgnWSUC}#;Gj{r-ck$W-?9?_S&+b6aG(WWy381rQ z*c6(1=#phm7Q4uwW-!p%F{bfHEdL?3YmUks0sR4(Rs)yq7YPl5&w_D9}p=qUJb~F}CVZ9_7 z2yk{Jg5aQBcAUt8{gKp2Dk5nLvK6T*cK1*$8V(nS(Mlg;1C2!T=1n##00tLJ)YC>R z+RBy4QX+(vAcdTT@*^Io2z0e*bVq*%wnP;i2iu(Nh^y!$K=l?&qFfsZ63fcx8;xcq zXa-p%W?lED%x%NN)Ae|~F|hg)!>kXW(<~5B5^2lE%03Q0#*5X+kFYjjyo-KNue6%b z#_?`CjSJHfAZ7u+r6&1H5^r*#T#L zZw2erhO{Ze==AJX0^o*w6%v{W_AJO+^_&KFu*$H)Fv<~HG@y=pJI^ap_FEhq=8^Lc z1}Dd9jaWn}JD`6fuc|NIKg;gf7D`}f0i50|Z>Q4tyBji*4wk%|oLM>K9EfdNi@I*xT1~3frh5&Bc?+*;+L) zI#aKekW1xR1sNq(LIA2^EsykdIy>ElJ=FBC1w%uFr^qwBZDBRY#OlLS6;)yboQ8;nMN=h| zqS>n%`{^B>cxZ)^$eyLWg!_?cQ)@q}U|}TBzQhg1wC_w#y=x+yOk|7sue6=l^5}R(yVCCNp>lx)B?_$+u2BGS*IJ(u#c0tOD_Yn))C-ThC19`c=OZm2e0ftj zghl>At@+cWDq({Te%F3O&4aO()d?-E323pVelWmemk9Qjm}S6;>gX9o9uRqgAbj3U z%w68tOa=Qn@o2ZWfh9MwUcW_0E$%>G1Yrk>mXQ(cs8dLwdU@37%*M1^#|Q*d*^zNU zgex0G1iTiqb>v9xASdp+W)V8Dy6u^T)rTjNFw1B43 zFrG=Lup5FEyS9)kw1YFdQxVi_CcaxoJGmhWnw}?U95Unak#FU79c?Q{NDP~)Ur$St zYd61@LKKJ0bAVH|*t0hcI0~8!tcZKSv}${zc9_RXaF+LJO}-CWPcg6eXswPDw8zTI z6bCR$tpi#wyu1Udte|qGkFBG97<8|pJ~4QCa)M|1QCcfguxvLA5TphflmfkoFOzl! z3!Su250Dz()>H~>g=m6GMS`l^4oKz7MFoDGZF#I{Mb@dlks0kFj}dEM{U*sr3Xpv< z4pM4*;hUDC3+iNjm#UV&sv^6Gdo!n2AAQpw&knfwJ}ex_}+W4D=c(nAhYj z-|UN^XP=gCG%fwNwOrnc1Ymdv_M5u;tvq~53V5AjvK01-^Vc*>L&R%X^Lil_bk|5$ z84JdYDrnS^Y3N$aXT|i!bx8v-pzFeTF^@wZo}wW+?M2JQY*27|(XJost^=X2j0t`% zi{jJuNVif({D)rYI#%5dhG}OR(Ckg4wZ3RfQ*TNpHjQSps)91R<zmrt!u*9R6>t6nSP}XWPiicbfkRju z)7&bQ``B8YjRrAwbaXPhJ2AtfnVXjVIyx3VjwNEM3@CkI4~>hoy|lL z?nu$>J0oTyZl_aNLnkZARDUs-`*xUzer6tbl~AcPIWaTV8q8xGg+1)9Y{U#n+0|N6 z5#c=H@}*GRsn-Xp!_7=K>PFFTOyk1}`4Qxe?*I2PkKS7U;0TQX1PEE1h5$yc#1@GL zM1y|2XaW0MAWUqck9lHDXrq0;r+XJWAmDny-3ejy8%|7+xKCodQEYb!>uwsPOXHqI(<#y{=}E#|P$~NVa|Ih)C4JX!cw{MW-8BP#KNjz1w5e)m^ZmgdU%eXtb`Y z_V-&nT4oKEwWOaUMG1|Qe4<#wZXeqxCiWU47Axx6ZFgX|C)h$#3%h2vQOL*kIacP0 zI!#HjY*-5-i6yHM_5iP2jv)3Atp300Y_?Fzm68*Oo=ccvT^q!{22eK!J5PTFTI;W> zHmxcfE>}3V&JMNNsOj4~Y}k!u%85L-)>`v{mnD@Ik@Q`29+#p-tP($_2K=R&LuCMq zn0oM+T7KV&baM?}6m5l`vXtY?AH)dFAJ}SLF$JQ8PC20vSqy~r>W%Zk6XH|2@0C!BX;w8#3BM(7tyAHMUsH1KXWbg z;ka%YUubmAq=nvx=P>U|S0-gtFp$$_qAIc%9mU0FaSZsx#IOXwS3m5-;xPxIQVPt` z(-}_lWs>e&H8jgpY3qV%)@%8oN3ilJ8GhHnUI=`2sL%?jlKr}7s4#mNZH0hHUoeB% zFh?C|kMV*u_VVk`xu&Y?Ma5ox$Es$BVUu)hNh&$XTQP5p+eXd)GRD1^+2`>~K{hwn zXu|%M5jJ(Cy|4h=Np$m7yCQpv#g_V_p8?s-~dgFX(zi&rg2&uKPwxx{jWX^v+WU zKr4g~UD@_)a0)gj$)i{VK(uk`QAY^`!#2!+#NyVG5O#FQV$GwNfe}u32?S%>#pECi zMLJyoNMorU>ru??5d>oKmGdx=&YNm1r(U;Tsr-4&2&e8nkREz(MIO`4uw4IYT$ND- z_`yeFwAWtK%~!wFfd4gAt?}9WYr5`)tbKMeOSj7ug?PMk3+AOE7xx{=K(E74sCG=_t8ktIdmr!67c+oh*t! zo~iLICskgmwzMIj%Y9n8<@}xiPdMD~84@kbO0J_jnrd6wnLZ_?^!N8wj364TAF7Jl z!MjC6OMNvZ2aQf8RGE#U`3uz?I~<-fjQE}Rox8IXMguXz?VwowIpGuG5V(uw3nk2( z?&14B%-ia%DJU;f*4VDTKFj)*>R6M% zKZ=$hNsiq1G&VvE@-jk;_}KgBlQ~6+Ck{O^@rjzAQERc8^+yZMDUy8069E ztll)9L;Y2fji4;b!mom;Bt=S397>?aJ(rw+|7bj}6CfR|h=>h7eOpA9q*P|~Gd0V> zRugQ1g?xf873MPeC7EZ?%S(BB0SSR#Jz8c`831CeXT>f!7Zk%D~EX50-dvWzB;lLCj;Ov_HL+A5t}0ohrJ~XeO1(7q9}?RjgbsQwd4|R@?WZ z!K=|=A4dy}p^pxh=-N0_R$v}!pGI#Vu`1)Qr$>gJfif^AYG59DFJQM=ziF0kRlty( z=K@;Pa)GQa)nxBQ0aGuK^AsK54r1i*6`X&?&-#|5U| zKo(zXpmaMrJ~@rZt#6oWcxMOZ$3!=6>&~zPUk>$RD%T!B3=a$ErGx{Rte*e8rZ2w@7jHhJ;^MUC`F z(Luq1EQro+RRzkZ7Yc61JSm;Q8XV+}u`oYer7L6T9}&CXnF#b9Cx`7xT;$5t@QcVh zUs>FS{g7~qfSd?65anf6wIL4i_`vLmj8U0?!%{O=2oIudin>^iRTOlsD^e^Ku`-#d z(W5hqF9n9|zp_Fe$6jnvwD2AFC`}vIXbZYY6DJUnR2d|!QdrYb`-)iisbW1xD2C--&UYjmH7YKVd?}*pF{NbpQB!owuGf=OBg;=x8K_60x=IG&xt6&`3cq5 z7>sILq{`cQ}c*6#EWCS~q1d*&!z5VRg4;?IZHD9p39rhzZHaQsdH@jOq zGO&-RiTOfbrmmbMpT%0lg>Hr0tW50XO5?lXPa*{t9jA0wTfkO^*+#oYlxBN)6m@=v z=|}i?foY`u@>>q4)Ur51#%2z;-VTUx?F$wxMvySxjb0dP$Uzb888T1Q?O4-vsZc~` z`t{xPs$bKBg{2UH7Q))pf_QMzp*1oGLFO>c0d*C|%eR+DD!_;!Uyp)?JYAXNr8GP- z>FOXZ|Cd~NAf?r6XdDsy0tR-#5XA)clM|p4&##3WBmPBtA__FBnT(rE9_mQHFSboLZWz}XT2n5AHk;o3fSzo zPAS{z=DOJ)J8Zh%RNa(dusJV0rQduGHr+s&`-%FpTc`q3iqocAn&9~VOK$tW;3qr- zep&w!_ibCAG4yO@*HY34foJWZ5fsj7g{2@6!P^WY92ZbXqr9n8-bnw6H$*UnT)01_ zRRIPyvEj2ih>jrvPMG8R3Th2p8VKhF6O7|@16$G>iV_eqk%1I`Gt#(_7zej%O2AZH zyHG24x{Q;Y|AKKtFOY@Va;=yuS2m+Ct}Bgx@VXrz7Uh5(jANybip@*mzTF92gCDVZ z6dR{K!3uev%ws_W1t}(Nie@F3?Ucb5w-RnJR%kNlrfjgKd|Y( z+`ada=Rf$t#l?NNlW^sqxc|X^0evjTQ5M5v8L)Sj-F zyrsvxPk3JN)YVkZt|!c4vOt&{q1PShMTB_(mhOEor3bGg%o}mp8{P(ayA0+H^X?n4 z&}MFCikqFFnaNx4TE-^fw~ME4#*zByhd%J$cfaem-tm^u``+f;e;7`5pD_yEz3_q} zRH-XXVACAW%QCdm(GAuvgV zCc8E`_3kyW~-!?{>A zBt+x+9M-6Y5Cg??`2QnBBIz`_KbOs=bV(Jk^>DE;L{HBDuj1YWPOhsu6Tk1(zVG|K zcXd^DRdrQ&RqxfScBv(`q?Y7GmUl~D@QSb<+hDWAhM0j6B1~d4#uyMFtF-_VCMGOS zu)z*Cw%I)i1D+(4aTq5d4C(rR=e}3H$m&*iTm1ely<1hUUcKeqd(S=VcW9#ceDN?a zWVA*DuyDQbG1_5x{zfni*(?qRu2KmZBOET4y6XUebSKg&FVwLde7icWL;TM>1o4#~ zBa~)?Hr?pFc=Lv##_UU>aJjRbjR6l6St#DNOh-D@5sLp(Uu9o?2exsX@@VKnW)YOq^la52Yw2jelwBRt1MaWisEcFFR{d9qAq z0^4>joLT3|HGxVsxnrk-po+FlfgN7h)Dsj|$RJVWLS>-zMecf6?9Rapkcje+S6=ye zr4xK*)d<94euzCJ<)Ng8*<^%kS_c?uZi43~0xeK4AUZ(V z#5BS>jj*+9Q8A>fCe%a49<8I~*B}7I6U@ikDslL0um8LDs>W6V(wNE;ZR^f;k9I0l zM(Dq8^mC~BTg4sXBgnkk$G*i^MytLeYLf_8RU;ybt% z)H6qRl!76>L8*1mJ~=sd_l7%qcKy*0g<8RH(g6|9>9rKD3+oKg!LcwhpFFdF9td=F zq|>erqai|pf&tj+y~6V$m)&UnIjn`E0zWf(QXf<(2KIdK?#Wx!PX^8KGQy^!Fqp0S zUz@aUZ~PwyXK(6qx?Ha0<_EU)_LXCREdbgdt#j`XoFMlAzdR$ zWK@ETL7Ni}4Ulm^%sQ<-CXYW938w@8O&e%_qE_g`cpfh*weqZ)8Mq*zB4A2JqFKnS zxj5$)zH%{>PQq&_X@mtK7hj+X8bn1X993zGsMpFh1|6_AV06fkDkyM`4~&HR-h%~s zUKR8pG>5F@Ewe-G8$peSg6Je@JnpEz5R187X@|}Jzb)oCVC5Bp&llCVCuuLVMDZOx zRHGms2-+PsK=@LH0+S!QmJ+*TArw-u7V>#sw*d1KV=2SF0XgAC@J2V6s+hf%PcQ?K zMn-E~EHTiHWzxqJ(U{!i*_eK7t`SoLho$Ao=pPt$u1DCA!C1o zicP;=kZ+(sC^nQTV!xbcEy_2bEEv>b`fa%^{Z{e%!rw|iUGle{SO%#OzZCK7Xbitc z+?cRgyazD=9It*RU}@Q?i0w7>8sKRNkZUE;l+RvhD_&Twp2Duu$)KP>r4C003@)PL zBN86eI3Nrq`AZC#v+xEx@W{?~zt0?UClH98vD)o68!+2k1-K01+3@>n6=X}Qk(1}e z4{5xyrs8YJF{Apk2bWK9M%01d(u5#&RDW3U-mY`g``C;lFCZ$66m zmi`fOs3BIm#IoRp(|=y%ZXhzk=?agjHNnvM#Cny)>l;pHFelPjb4iU zHu(b*KZbW(^1#apm6fin08fUkWy*(cQ*+c{Q;=oP9!gU)fQ;-=u>rTl{1FC+Gmr=b|b-Y&4Op9z7}!j?I46tW^QE+2KT{xJOtA zuj#b2I{TkBU@0h6+T`oA&j~{1{~72YBq~zS2k`Qu7PE8qI|AZbfSm~+D(3G1s@`4A z`Oy+y)S18_EM9!r@r7?`Un+eK#bs!CL@_wOP%_P!Fv|59xdsy#4kRg?OXe%}wF0t_ zWTy%YVrz1_hGKUffv~h{*Q*e@`sz==PI){({41D1QNNyAC34UKjr5Ui!5XZc|R7XpP zd4fMb*$s3Mi(v0ekt+vtq#ehk8@Tl|9o^NEvKQExCkF*TNs(X(y8&1m=Yld=?FTm6IqG;G&qRdG}5oyO1z-o58@S6qitKdZknkn{O ztg8-4)KXOm9YjD3Sv^>7Lx(&h{MnI%JmZaeWnOd|@l@~vaF64RDd?ot7ZFoD3JS%% z@I$d_BUwqZhflaauh9U%FDBSa79M$ec&gB+m38yGRKPpxntmM6zS%pgbS1p93xu z63L+lQxIfgo=w#9j(12eQ1y)0FNK{R0s#liH=HdsT_iQ9t`z%#erIW%NCQ{ z?hfefK(9lD&Xq>wZ^LKm@vOfkZnbL@iQb^_)jt&O0)%j9)Ex*K^uzVu;GaCGeOw#0 zy8+ut;*t>eqvna(7WQ~8vtRRPv-MCP@HkQP0eH&fr`ZFW`cr(Cm|X}*R>D97-qQ{) z*)Zu$7r~{2v=4R(xn0d6fCds!7P3Sl(7_uV6&Ltb7FN~SsOc-(S*TdE%DQ0qH?R+Q5#wVWsf!{mk ztVVR?dk!CVh#i21a5zIjx7+Xaxlv5xk{V(X0QhD}4~=g19=ThIygihQ>ZTYYtq*RY z_FQ>i*=vFj06R1O8O=4M=vYi}*xH5L3LnUM5J#Ipl_Nwa0|8%cnl{+XCaO`Qf{!RP>*5oECD8|;Aa&*C)J zznWZu3VI8nRCqf{1~Zrh06bt1C3T~SU=&LVB1GGgaeXILZ|avBqcSgB`<$S7RFVrC zW}f&(_(gHO5{Zx*4nhZ@_%V5Z#ibFN4#rv)9wzLP~GpS6mD~lWxuP>YZSgF+AT`CnfZpr0xfm*hwQt8PK zRksuh<%qD+=YwCUt1atAnoK_5RmlL4m;+f)$l zCppI2P>_(Z3~cw3%#I;R<66O^gQ3&G)u>Vju};IOZ=_l;Tz-Nhl%mk*EDH@od#xAF zZa`tQ$(kR=B<$2E2*sbDJRq%zJdDAv8XEGbP(V#Yv?yrB6-p0&v!;hzL~pqIh`#absK=yNnpDDpjCpf*OA@$TaGU^- z&u%Ym&2BblrZ-k6G^mcJ)FScX*}rqR!*DejtZJuTwj(iiw|MGo9mYGS!_ZPk9!!bR zIcyc%(4ctgKR1b|C;*gAG;a_-2flGDs`7q=^9|km_&V?au?&3?iz)uf$ii&sy;N0- zdEJmY8kWd?F#05Je8>Ip@rJI)9*8+4^9DKoWXOVb!2F4$-&41)*K@Ukf_CQr*qOx7eKZ^yY)>V<<0X$L2v@8Qi@B&(0wmCm8gCYF z2UI{y&}O#Va*=S}J`qZX=zr)ellQEl+35>C07Y)F)yb(U!R;>sl)nedTr&KATVf2S_lpE4l5Z+u_ z`a(e#2J9TKpp~_i1x%kh;B1L$#0IZB(5%lEh{PMQev-uS7FoxL*^olK-HRC43Fuwd zu`f#_8#RvbH$*r;u)Z-Qv_XEL@H+&WS1uMe!OeQfMtsy`y!Bz(ym}WgSY5K#l}V|t zM*Z8zMi7nL)7u0%ZO8d@ z&lvY!4q@ulXhs~zxNR7anGAIDoM!JfT&J(6qA*_$IRwO^f8347Wev&GA&*-OM@Swt zR_R~MRU(Z<`buKg9{L`B-d3HGw0ON|qZQCx{I5LuRqnqKu z^Z7~$M-L56sCzrBDP%4JD=^lGr_=eN?s_8aawp@X>#pbwg_Jf^Z^sC7EJF4AoyovlifCJM}E(qeBOq!s1zO)%<#Jx zuv$o2Kj5)Ik;Wgn;Y=_*mjr*uFoKwnc+xQ1Tpo?OVL!1AbfIecM8VCGG~P7Ui4uo5 z?XHm@HPpA8}=#LM!~orOKj;(y*nONqM$jxy5;7ZZm4z=Nag-( z!KpghMdP{_Ct1u#h$Tq>NcW#-Aq!GH+` z_v>^{jZ&pcd@5>Cmy!OZFscw@YaTJ`05BLUOol?ePrV~Imas%a;uD>n+qdt&Y;s#Z z9|qj@(BSa+U|%(0a~VT9zdxU^)oSDG*L4&!nTd5%n5_An-=Ndnr$8u{-uIgb5=QU~J4zuUjU0=y(;E1sI?k4SWQ3lg}^X4Gka`tj0- zYq#IF93Z$-Wph37*ki&yf8Zi)SdKf0=OPQJgxhmInED50h`b-)9UR&6h6WU=>Z)%{-5GjX!nD1Z8`&>xRycy{WY$YaMBkL%lN(RKl9P%$#Zse6C!_JF#CnzK`tjKmN{`b#( z=9+8B?ukvffyb=1Nj!UoN3Au?b^)nct|&!YTUs7$E}+gwtWAu>L! zXA#4oGobvVZV|}D>n;3=EOZ%CZ#oSsfVlq!n;A4?mKSPinca|%LkO3b0@gX<-z5n zLE(3w{p@?+H@kfgQbE;Pt*~6GTY5e(&S|AFLHT1C6Wd_0eIH*_mdPM%INe_O%fclR zCWpr-R4g#La{>2QSz?(yly*+-R!s7tBoDP6!gk5@QiG6Ba{k&A&*X`8A@a|J8>G2* zf%4FVu}^>cs;eNYg7!Rb3%VuoYbk$v<(XSE;M;RtX$ARPGSP`^*B27K7mg1a--!BJ zb$(Y$V&bby^Ah?cdag3v>jT8RK{L|Fi?F|VS%yjSvLQkF3_zevLYP8wfwxI~E*_w)lJsxK&lFfBZx}4!g z9J%_0S(t-Q`dR49L1f%k`TRo!js~gFf}bJk9-N9LQW+rj`9tK4l2v4=qj>5vjLxML z42!5w4F(bcHmD>XJ3S<|!JUEskeT^pg6<3@4ZDSBi>0<4N*-x-ZeOraZC7ex8a8W< zm3;EZa4tHeRBNbqn}1g*5l(0g;jy2!=aTq=71gQ3)zP}zi0=c)VyU#n2K&;H0i{u} zI@_;Eb+mV?U_^+K4xDQKi}*bH$puXyfxh}P{%mG7Rhu_EM8N)Nk=FxZI0$<>^TV?4 z4ZwgRBa5F&0Es~fYeeo0a~V-#Q0_o*aRE862#^Qm_>cztk3NHQ@+Ha4!V#G0eZi2z zn1FdfW-Ndan6t+t5W5xU_Y{6rItg113@s8cJ5(a7Xb%GmueG8{DPX0qbiw>?)3_3< zijYY??EoaW-h9M|G&5sF9DDZUM6EXQ-r2t_3xAat%swRjXoA9I4GY`sn=au0+&KHM z<$e3=f^yfF9z!JvI1o@*>R&|iskxECI4E=7vneyfd=&Dnhh!eba85(YqI5wwQA5N6 zgn&eOEPx>Hd4e>6pQxciG!jW<3ZUMIL;>vOWCMJfW>6U5J7b3Kg461Qd}?bB6>5MfjX^Bo`zYHmf>m4v@4{H4{JIXb~`xspmaj7!u}!Bm1wF=K3_a_J{A zC6POYkl~DD(O}|eOA`5HfF-iCa;!Kq_H(UWawUM|pfnL4WPgx`nWr`AF1H$NR)4@^ zzSM}^Fw{(6uQPd7CQ$*XaKmJ!)Zz8-MlJ1Vq%u{sh%@5s8}oxv_>|j>A#C=QV*xlv z3{Hn{M1?Xmf`}?#=BmvRjwYwjPYyroV;{Jlbp8nPjE}(2Uty!N{%JtXSXe;e2qs=d zWR61j_r&qRXagf|E5L$PZ^(@6G@Qi9f0?5%hbXhiApreV8iQmwrhjj-C(&Lc&bQ)@ zx`@H?JeQ1Q6eZ&DU;WswuUEVP>gsc4(gEo8?i5VVs2He#9|At`Xf}~h>ycXqz(b`G z(UevIAp&Lxe);LKp)_y-ynFK&qf|vVk?&Dy;lv17QQ;>5K)2XHxnp!w%d&;!fBJqR=hw;~Wo=%;&C>+CcmT$~&qx#Qx$0$b+({@dZdct1Fs{ zu@}dcSPa9uw9*2q^H9a-D5VPIQ0we0l~O$|;~1a2r0V7m&Hl6S-G?NE{p4wyZfTHL|g2lv#7|RhocFq|~HDZ-FFeKJbuvF*oq~hLnu37`+1&3vn_c40M4QVWEJR zHBL=ExyX71Q#m_N_4Cs8Y*Y5x4&7zRTUf%|yEI|Nk)`~=^R#`>9Bn_h{Ky3&ScY5m z2Y8T!*#YJc2NyYt3!JLpbSTZ+FqDUY3|0wih{kIp?u;>3CZl?`hvW~bly*Z8uOgtb z)LF38RTKJbpTV+(et#ws4o5O?aVRW9==KCw<2n8IAf#2yeDSP}bCVY+;Bk6_9!AgEs0 zw0VnCgHRGK+>tET0Hk;tYX>ri=*2-GH}#3j45QOkiDtalA?bAgK@2 z0yO!Y!v)tAaxl1Yi;K}M@nlGl<9uLK=hX-a<^l11vTq%#1&;#C2^__uPn7&+l`bLS z_}xBbbx${b?utHxa$0H>sYIa!wNVxSl1FE^dW2@q8)!@I-XDnGu2JnZ4xEN5zjhlRC|npS$tOoC|uVMv2r5i^1m9x%5L%l#rmSbUs;6 zr%=c7cPh2hQu_xaPiVX^0x3>|01rp(gR>tKdZsqR&*5ukc`fg~>>%$6qroCFDs|~K z$uY?a5Isn7`v|X2HioxOBRXeC%T7OIoQ8zX>8*EBy}oc#3`u=}7cNs%X4Ymd6qZyJ z7b&@RS}_Nb&OvbpOBVI!9!J)cy4}q6J#xOcibunT^y#r4wVb?=tB)b5w zhpD(5i1s_#M>(ZeUfp`eziIoNZ3rX#Le;p&!nuiL%#Db(l3hjIA=et(YpHi#o2!;_ z0V-erS{78-Rss8nEW;WQE4hIFd)Xtaq<_P(4dAUL&F)0h&zyhi!uJEhh%Q_|ED(Gz zKvz7lSUg^1k1Up)19Say2j;~j`;%4n%S>S7mfy>~FtROMClze7z&38Wa9tvsS1&+k z2-iv?`5JqHq;=A7C7v_}+V5r8vM+Gz+k!nC6%l0hDuKEnz^o0%4+{%Su*^Iry(d4| zD7jz^Q_~^=TzGeH#V6wx*NE(*16Vn(y5#pVOZ13hVFv}%z8x2?PtL}KIa>z30G)Mc z2}yNro%c^m39*<xZBl>`&aG5=*M zi|=kR_48C1zgK48u;I$j*3X})9HHjL;7274v7I0k7p55#YIkXUrgto`gZj zcQ7f)&)JUnT_9p12gfw~vRpykA#+S_PFOB_g377iyO=XVdX#vhFrv5v{doc7LsDre zJ|w>K_z?I%m+8;vWnOf??j){*OknUGX`GJXxyo!1BCQeLD2a?ulY!1(g}FV&NVxCs?`pY z!EO<{i`7E0Jz_JN^*|L>px}tv66m^Y^2XW!lX2(`DkX~Kx_1o?RAN?}#Tv@>_gxe- z7(V=AG55`!__ryW4WOc;)1dRYL&zm_I9xuf&*pLTRf_5L@rX^WP-_WpPpfgeyOY&= zUtgsfL$O`0*7fTgCNj-Gs&TH^!Scu`JjH2`8eA)?a!yMoqA3FX;3-RwR`{;YTk)J5 zx605^ksFOJBWMg}EFed*)X)_$L_1XBaaeD9g~AZJWpreqU+F@+=*bO23HzmrL#=2` z3gKL)TzYLuJJ#lSDUjxth1Kt~Nsl49MBXU0oAkiL5 z;yl$2I9O2SbQJ*eJwzUgM^yHpqF0N7EM1WubVqPMWAy>oQ)suAbay>^^TB|AgRvM*ru)D+$!NdRjgBgq8W@rgP5?cns=#lCoM`Vlik$+^Hf8kHxL0*%QQ z5!cD1a^1>^h%>Q%!8nOvNwC$CQDUogX!ma!q77YrxG~hDYMTUC9T)1z?m4q{%ldUn zErR7wP6>V3zc{ve-Y~^oyxjlkL1=c%)FrK!wB#)b%dz@SC@;%^SKT$Dcn&tKg%3jF z!z8=a*g9kpb3gq#IEZm%LS(&zkVnP{=fGs#v0K45ZpCMq9cA83>r`48BXommn>e-^ zubu7U6un|LOs%XV^{kGpL10?KEB%(vTXV;pM+{tj|GZ-f>2UQubIDTjVudhqs_c`( z9m1~}0F3z@0$v@3*aTFqf}k@D34aiUDX_DaK_+iTbK>^Y?RO>r;P&+GcO`^%52WtD zD|6QasR!=L@Ok+qM7jJ75kH-X#=KFQm&oWr&Ss91Ju#0^;meyKr$@t5;e}Azke`+{ zzskAQ_Xq9L3RX(PgX{N<3>P01Zu23#TMT&1#ex3uuN-*y zW3z)>?!SC|BAK{)=dD}DHx#{Q^C;v-DgOO#&}&0WyWth124PVFXbGrhMq;*Eh^EAZ z;59*Ao$(K^TcDV)Jn~(_4`GdQ41R&|Z{V?zBUHh0_##=Uq5d(qp}P?UMQ>!=xsCHA z)H0Q;Gi2tp_ccVme=hS-L~9Di5b*Q*!M3=~*5gbhLc-NCWN@M6qyW$xR1eW0H>qZS!rQK;T1blxgODcos+ZxV5)>tB;8{=Ba1-YB!07*5D zm&*k~%`~b_3Zr2)x1a}Bk`tn1yohHG_NbL7gg6$mh4ZF`I2ZGPm1T!~ZSiEM{CkQU ziNJ=`g?y~L`CNA@19gSQdd z5PWr&uhx7a*9u0ts6E021!J#Uwk08yNs~a4b{>j;rW*yqlp55SOYyadyydiXw?eUH zXF2Vl%(PaDsl1%IVhSu&l7C%x{|ecR%c<3jBHcdX_cq$v2KooLYPD9U&Z(dMN2kfE zu@MZ8&Cw?KE(JOZkiUyY#e@T&YZ@DSB`6e!jU3^P|4XYMPh~s)_acfau4wEU$tw!0_fPm zhc9wDov0BE2(P>bHFGK!SmTldD4h(XHzW(0r~2C302E)D+z^iD3tQH;wb`uKXKglM zNr;{!C}rp`qys@Gfbf)BBt$B7T6cBKYJO0s^_K(_YESBQL*Y0dGYK_DaeSP_S zBvR~XXpoJ>62QUxiufcVQYV2lD@BJ`T?sLUDpi)z^&6DlDA5_%k;t@%azWw1QeCuw zhjckZ4v|g3wD$HOyne>NV;W1=8DPFnD8~@j(X#uT@dLZlCCpd&J2eEdX6L}vV3|Yq zij-He8(|?0!x-VluAk7Dc0($a#8FL4m}Tr!P4*~FsM!Gjy9)9X;iBPvkA4yeBN{-37lvE1fk@j-mFF^ zN8RpE6*|_1!)d?OdQZzzlnh%vo=~*i4TQ_cM!U&maFl}%0afhusc&{AV+eczLM=*t z*-*q&R2xy6#BVk^9Ql76jwgMOgsJWwN^gBZmdc=CEbgBJ>`ys2S#$q1^wWExcr*V# z;7GmAZi2D;)TQXeizJ@&d+4JDCZ`M0P0sY~Jm6Fmxra!&P`$O7|9@z%&mQA*YELWr zWX*FbuzhMxa|+ppQ@i#kZ>k0pfnwTwdQ(Z z3Q!Rjd~PinVS6Fw+4|)zV;87bzkLpSSswN>X+67<_wfOC6Z`y9y=+iy7gw;PH4OWK zK7=qv@5P7~Y8VgQD6-2D&EdWN$~CZ1U@UFFOw?Q}?B#3S!gX%@@1vj3V^+Hm-Hn<% zb1+a(IRI{Hc5qzG3&dZ==pyCA>Tse|14otucF0_Re|xUKzqwR@uS|kz=+M>pyPi55 z0wr}myLfR|H}sd?c<9=zFIQiG<;oA%q^0O#5lo#zms0M<`2*1zs?et`eYC7_Wro;3*sk%lWn| z4j#B}jv-5V@mXWa)?K>dUVp77J zQ_@T3ER`I2EG5;9s&ooF99omro2zwnplGTsuh92r^oB`PXVxi2hc4kUsgckIYoZ|( z0yt%&GZc;ild?N*9A2Ld1#DIbx9#m48}9-Hrzn^rHj53A<{pRLBTRkf>mI4_Ik^KVB0`gp#px7U{E@P7Q4koS+{y!)Zw&P@8UM`bRc%zN%zCTU8;P0|mA+`ygZw6206+DV>jqq^ z9t|hrZTWm4s8*r?*D%$c_5)!(m9F>q*YkN)j@=L3RP?M`35++2p9V6h6g`c8$9(cY zFMJYuVU~5XhOB#=wShL|oN*k%5QVgW*r?LL>lm27)BVzmxj+Leggv}%=5H=6<-TauYXG}3P0pG1Ja z2E$n>v=qCZkkghZ<_pn?S?~5FGab6?Vv&kB3|!P?G*GZv;*r`dHk41+iU3c3M%Le) zrnBfc!9FhWrDf>Yuvq&Ckuaq9q>9SoP}enxpq)eB2JO51p6ZGl5#~1sJzbwtD*p6!OTGl&v12eqpFgm{G96 zuYtZBWmRgGbR~@mvX#UGZsfF_0KSP7FK(gH+oL)+8)z7Y#{ujPae0)iL&oj9M7CiI z#O0mv^30FqHE{i5yo$4gqO)TU*Ff*n3xwf?An~gl*h23jp?3jX zyc+Dc`5#!)X2F|-DA2ltk8q3A?GGt^f)=RaUM~{kX8d7b!toW3>zBY=1W2$HcW5w+ z8WRu?xYbh{s$n4{@myGtQCNORkkEz}2C@;hL&hS^=bHePF9WQ$&VM$U2?9`sI;vfj zYg#PxV-`Uu|GM2`SE;R5;QFaeW|U-<8%UsJn7CmJFY)>lqxcn2g0*+CalOm4K`IxHA9U-Mk z11NnteOXxQd6f?P)J8jstt;m5kV^3yZjT&{NwNMn=<8hl6C2xl|UMj*6j?DW=Pi>@8twtmH{OHK$!Ty12HCYUo zJ!Yd%TWZ8+T|2VnN^)a18;R7a<5#3J_Wd${_=R{6kYM++XE?1)5D-!1+}NjpXv(SO zf=dYaMNV=8nKF5?T?h((!J8#KoWQ1jOrkTrJxWoDx<6QwQi&J}MVd`o7R5(B$P@{C z)BrT4JoPMIT270G(O?e8lo}I9tZ#A4Gf)r^6C08T29cG8&vidspkXaxqnCw=$ZEsK{Km#mfK?q zU)c^1kkwY{+Si@UBX!lZFBll?jz&$!1TbIS^v?XGyaN7Ws@moAnT)RXswW(<ANRpt2M)zMRkNbC9P2+8$<)_kVgconu?&gxy3cB8-~TGM!J==+cy(=`ax z@_C+G7mS;s4?l%nXHW1m8#j!ND%Gfad~#B#A)s|FGk!_0$m&QOTb8@7itv?la8~GL z5?{TM_roai+aH!>h|X@2k}=N3S{sHz0A&Mh*4scRcf+`K2P%wgg!n~~Ol#;NNL|%2 zQL|Z)mb|mOV=dHooJSj5T_;4A5ZYEn{tqrC#et7>vMJ$^@CM#d)vf`FM+4R#!M_2x z49Iy91-S*URVynk*n_W8rwCTd+>8kTS!kugT_|9AwrF~`a@r~MJnFLH&qbxWtiA>B zg^A(e&q-gP1Pu_mW?x$P2|*{g%RuvC$ebD6YYD!R8~FX2mG(>yrY0lF;kZo_JYzM; zRo)PX%d?b6QB9$%k}MWdhAg0aT9VPJBG!lbQh29^@lmeW)v%XGQGfwhDMX3L#QQ<50NVvD&hZ1bwWYl3Y>HknT z0=sHYZMeT$4c#pqoqax2>d3{~+kCz;gH2;nS*zu2dnyD>z5Ep9P(5V!pF>ZrBHKi+ zcBd1Hf}#RDp|?YlL!F43u?k)X$?!(hV2Gu~q=0Y_5^cIk3~kY`p;tNE6&ipB8|EaM zhCYNE%E4ku5m+eJV8xKcnqID%OEzUthSSbf={`R|?!3l6F$a3v5sK!-{%uQO&>l%P^qdB6^L<2xg{L(5qqS?H}MG%^(H3za6^K!aIub*^m}q zR3hc$Z>4kC!T~;~VU=0SU_8%4xl+1LF0n+eZlJ!hQS0&3uA~^ht+(=JPSYpFa0VZn;%%r1EAc42dJAFtmDU62ODP( zahf_T8PT3hrHkWgF)wxqs6)=F$-L2?0Th7?!VnJ;4@)@~<+8?x<8oi+7 z{!MOGqLth|wjw0T`YZF7avT z+b|((Tx3{sojXCVYa;?)%^Iuz*BQHAqxJe+@&0nz=Xbj^nU39k%7AwEHv0_I!uvHW?%7WFP558eDTXp(i zu)VD#8%hQTy+HATY$V}`-zaC;eL#|pLT(F1w zdQmeeG$i)0mce=+=CHc)yM754vC4t`n7v-4W6W?bHhgD%C$^Ko$-(!gX<+3{VtWYz;EywAeTXqz5N_!z+|$=!`w3&N zhZ%GKHQfLB?8ScXUD&YSSAt&w`-1OhEQIrfzX?RVS0M)D_wmCBCBSjXHvlex>!)%3 z432Ai9U&vfP(1ptG0~o8ERWX$&QEemjkG?!$HWml(T9i*Tn$8N2l3*#3#JgShq;*nh>B8N2FU#;#`A-p|-I(~Mn< z^Im%r+Z&8shhwh8vmN##b_)Bie~qylam-DRGxm-t#%{)MZ^i!G@c#C9V*4^&=k8_f^RF`Y1utV?e44T22e2Jy z>?z#WOdDe-L61{-_A}W3vhJS!Rz1P zyx+N(v1hUWySTUSy~Nn_k23bc#~J(E!;F3Zql~@yG-E%wi?NqaGWM#Ku^(<`>_<56 zC)14mRLj`U)-(3=cQW?cbBz5RW9;v7>@Qwm?3YQ#e)XvEZ_TFgCT7%TcAxNZU~9d} z)YuA&1{+WSZ5sQxVk=_{VgHy|g+1Z7tcK4SyiN$N=D%UT2iuL<=$ITfBen>(7&a%i z9&91Og^XZqI7fiagUv7eH#Vj?%5D)~L{-U0P?Pcz)V!kimouYS!1f}GU~}SncHsf0 z#rqh2r#QmDqwixl&dOiu_ki}m_jY_XW3wv0#MF4zV{>7@4(FN1J^w3SQBRKFH$A%( z_mRLoD)H*WYXGkvzMtN|$@I8yD}MJEc(28KCun2Ed)nxkiDq=X8uwVi_w@XFY)-l^ zUhUY-IHzBDja|i_L14ve>^)+L)3Ao`wD_)|c%9R*51;#RE+amxh2QZ$7{c?0uW!R>XBf;tRO;W>h176VWuhPnvHCZ=x>mdtkMF z6MYAoioZdkumjKiCc>GhPkw`Qx^RDf^aDN%_o9OM_nBGo7og*-INyh`{R8NUbE6cs zS*Y>f=EQ$yDQppJR@@u)8TH9qHarL8eN6qkV557HHzU6<>9Tz@L@+^Bfzlox*+}HqWBX)%*{+kN*Yz{O>pi^=qqt>HYlnIP1h`XZ%dYmOjiu5uy9s5b(cY8hk#})0z*=PrUQ^*(Tfa5}9MEXJ)l@7%b zKM9~o$iFD$@!_8e;BZ1yWFLIscyH)d34_`X)er(yZlX%_I<$v*djnb>(>4DB zt+{y{+EVkUXvdoOqaANPh<2j+0NTms-Dr0-KZtf$^F_3~o6n%#)BGCRX?}*I&0pdD zF`U7~&u?OMehafSe}tB(blj(&g#7F#Z}@s=LgVEH$Q@weifI0 zb)xxmmVg!MIkcwc57Anhe}gvA{7baK=GW2YajgXFZvGf;jUUt7dRTF-d!F8WT8*e^_c0J#7 z6z|*kleXd8?_hSAafeyfhBIf;Q?H=yFMBDilMl8dg<|oijH$M%JeOL1+TI%-#`u%>iz5M+i zjHm*Rc@Zu3dx7_R2fy16epem*t~&7SFG72xd+We>dl3Kyy5_ggnsCJ;j(;Al1@~6O z(>;MU$lE0Ds)#eZg0_UGE8+~)j_@;#@o&fRj72=x%V;O@q(wFbx)s@e(6ESS`6b$8 z_*Dtl`Y~D>EhY5Yt7uagMJ1NS`AZlZ&!DZ~S0#)A+JBh0NBNN_@V<;QT!+@e+c;)f z8RvfhGu?u**U8V;$1-7|^$Tb_@e~zM_=jk_`JNi? zumU>&0Bvvc+i3S-)Kx%dY7aL*iuQ))SJ9rp@l{;?`)C8rzeXFxh^pf1KSoP5sp9w( ztP6MkM`+Es^DZ2JFWPwXG}@zRyZK1##xbAAdm3roIOY>*X{2@IoYcl~Ki#}fyLq2> z^WN-6UwjMuX{2>yq`iukMp`!?Y2E0NSMZ)jS~vRT>u71Db)$Fw675l(vxcibgO+Gp z!_{v>OBAl*>JOp~@;1)*jDVZfaIFuZ9S7xWxEAeMhw)Rx6{(8hR&cf&+lIMW!`0u9 zb~|WT!#&XZouE$*ck&UmyZHOv;0!fTnD$SDJJoQ9bU*t*&l>LY(`b+4>UG@bt7z#y z>$uPNqow<-<39fcZIHKgpLNjWDB7{+$I*_Xzv{TpPoQ1jd^g&yICC9$_#E0v{;Tci z!8-1c&a)G}R>vJaigp*MSI52m04?=?9rs2@?gO>!xVJNC>E3#9%-7IPH$TjJai8Bs zOZVB!d%Tz5XD_E`FQ;cOdh-MLcBFYb+A)6Qc=KkwpJ?8Kb{($Ri@QC7b}Ra~7kB#| zw3GbE?YLGi?v}pY**t}I3Vg2@cl#>Z-RR$5+|P?>r*XHvxaSwp?!yT0#XWx!?NMC4 z56AonEpd+#jM8K9(CC|Yp|#-YMzQCcuwLpx=P|s00hXV`xQ7Ysc^K_s%#jUfzX;Sk zGv@3@yg$V@;i-R!mU!U2VcKf5o<-J%fx1`t3HfpGOX`9y4N+f4iN( z-;Vcx&352CFQEzdvN@NXlZ`!!LRNFF1@}vgVxl1FIswvY3#opF+XO|Z7+WNG_sTRn4$Y{ z%)MxdW9-8*H?jTr_Q&i1`ub(G0scOSXFq@$`hB!*;7A8BLtjAKh3g&wZ}}2h;^7DQ z+I@iY@B_TR4xle?!2W4a`vA^N$B-m&0G#tRv?us)zYiK7#MwTJwi{1zke}@!Kiff^ z?Pl!RgIReH=lL{RI?q9Vo`X2g|HS(f_|+jk-w)x5uETrcNQdx5)CSPwhtOZt(mXoE za^PKuSQSrn2xIj-XvZ)&4`EJy5A6g$+xq5L@O~1#a0p}a1+-IGa}TjYxQ9cyhf`>2 zjvT^0{0i+U{@bte^E`)NUC;02dVVL@^E%7Z`sZkghM3>H zCHdzlq?BKwB`N4Ap8X?e>DiC+XFrOk{y95_e*6VmT7!<^D$k;&HRu>$HI6}I`5E44 zcuVWiF}{KvgRDaPJ1`%Qu`+*8E7URE$&b)(LGK*Hwdg&qQ^#uC4zZ>Rb3#O+UD?*9@k@%$6GPrB}N*z;A;NWNp_S}m$z~2XXOLO-7*#9H6G@HJUBmW-S2RnNs$yt`Ap=m-_ex3F#`g@u0!QQ@q h`ue*swe Date: Wed, 22 Apr 2026 13:22:20 +0100 Subject: [PATCH 207/398] 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 10f0624cfb3..5506fe74065 100644 --- a/app/Exports/PdfGenerator.php +++ b/app/Exports/PdfGenerator.php @@ -65,7 +65,12 @@ protected function renderUsingDomPdf(string $html): string $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 @@ protected function getUserDomPdfFontFamilies(): array 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 208/398] Readme: Updated netways sponsor link --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index d3a408ad02c..340545dccaf 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 209/398] 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 00000000000..5c50c3f691c --- /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 647835aeb2f..1214c39fbf5 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 9aceea2a26c..cef1d054074 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 cb9dedcb25a..abebcc5eca2 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 379f1ebfaa7..6fa21ee2714 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 80075c3f7f6..7348ff2b335 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 5f4c16caf48..0fc39d9fafb 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 210/398] 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 7348ff2b335..089dbd67a1d 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 0fc39d9fafb..a06cdfa66f4 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 211/398] 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 1088e657ef4..1bccee2c395 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -75,7 +75,7 @@ public function test_image_display_thumbnail_generation_for_apng_images_uses_ori 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 212/398] 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 1214c39fbf5..8975d6e5296 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 cef1d054074..5cacec67aab 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 abebcc5eca2..b409c62e254 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 6fa21ee2714..180e6d54501 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 089dbd67a1d..e969d3e4721 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 a06cdfa66f4..5ff2d14a5e0 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 213/398] 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 00000000000..7a02656725d --- /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 00000000000..a72fb1ef4cb --- /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 ca1f2b8301c..c1420cb1933 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 ae808a0b600..d60eab71101 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 00000000000..70f1058748c --- /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 019667388eb..0cd657d7a50 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 70f1058748c..c185f3280b6 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 340545dccaf..2cc24c52e09 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 214/398] 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 00000000000..5ff760220b1 --- /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 b803b07eea1..53869eabdf3 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 215/398] 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 5ff760220b1..a0c09ba8f6b 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 2cc24c52e09..7bb71949d92 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 216/398] 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 12e5fe04c6d..f426079e839 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 81ab2fcd711..2dbaead1373 100644 --- a/app/App/PwaManifestBuilder.php +++ b/app/App/PwaManifestBuilder.php @@ -10,7 +10,7 @@ public function build(): array // 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 618f5bb8281..411e2197b67 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 9457ca78b52..b6342ce5465 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 758d6db4cfc..75e9a5ae61d 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 4b4fafe5624..869bf8c492a 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 7bb71949d92..8b81abaee64 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 ebe3838ef1f..8a14befa55a 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 e7155431c86..969c49595ef 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 930d407a508..2ca06b86ac0 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 deae153e192..4d97e6b5961 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -370,7 +370,7 @@ public function test_base64_images_get_extracted_when_containing_whitespace() 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 217/398] 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 bfb65cf0f40..3912541723f 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -120,8 +120,14 @@ protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): El $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 cb1149dd10b..fc300241bcf 100644 --- a/tests/Search/EntitySearchTest.php +++ b/tests/Search/EntitySearchTest.php @@ -136,17 +136,21 @@ public function test_negated_searches() $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 218/398] Deps: Updated PHP package versions --- composer.lock | 254 +++++++++++++++++++++++++------------------------- 1 file changed, 127 insertions(+), 127 deletions(-) diff --git a/composer.lock b/composer.lock index a70aa9a0ff9..4a56da48823 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 219/398] 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 9c60fa415f8..aeee1f52881 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -195,6 +195,7 @@ public function sortForPage(Request $request, int $pageId) $this->validate($request, [ 'order' => ['required', 'array'], ]); + $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageUpdate, $page); @@ -221,8 +222,6 @@ public function get(Request $request, string $attachmentId) throw new NotFoundException(trans('errors.attachment_not_found')); } - $this->checkOwnablePermission(Permission::PageView, $page); - if ($attachment->external) { return redirect($attachment->path); } @@ -247,6 +246,13 @@ public function delete(string $attachmentId) { /** @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 945fc258d77..2d402c34006 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -5,6 +5,7 @@ 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 @@ public function test_attachment_deletion_on_page_deletion() $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 220/398] 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 f426079e839..5c044c94629 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 8b81abaee64..30d4d17891f 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 34de6b80297..b9a99ec725e 100644 --- a/tests/DebugViewTest.php +++ b/tests/DebugViewTest.php @@ -27,7 +27,7 @@ public function test_debug_view_shows_expected_details() $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 14f310dc37f..9eadc470b49 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 221/398] 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 | 29 +++++- tests/Unit/SsrUrlValidatorTest.php | 62 ------------- tests/Util/SsrUrlValidatorTest.php | 142 +++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 65 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 076a653fc86..a9b5c138df5 100644 --- a/app/Util/SsrUrlValidator.php +++ b/app/Util/SsrUrlValidator.php @@ -8,6 +8,10 @@ * 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 @@ protected function urlMatchesPattern($url, $pattern): bool { $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; } + // Prevent potential tricks using percent encoded slashes + if (str_contains(strtolower($urlParts['host'] ?? ''), '%2f')) { + return false; + } + + // 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); + $regexPattern = str_replace('\*', '[^\/]*', $quoted); - return preg_match('/^' . $regexPattern . '($|\/.*$|#.*$)/i', $url); + // 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 8fb538916aa..00000000000 --- 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 00000000000..12e1765171e --- /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 222/398] 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 8b2882c6098..64192a021fd 100644 --- a/lang/ar/entities.php +++ b/lang/ar/entities.php @@ -173,6 +173,7 @@ '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 3191bbe3a0a..a1fef8364d8 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -207,6 +207,7 @@ '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 42cdad801a9..19e4f518860 100644 --- a/lang/bg/entities.php +++ b/lang/bg/entities.php @@ -173,6 +173,7 @@ '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 a1297e44613..0554100fdd3 100644 --- a/lang/bg/settings.php +++ b/lang/bg/settings.php @@ -207,6 +207,7 @@ '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 74c50be3b2f..5501d2bc229 100644 --- a/lang/bn/entities.php +++ b/lang/bn/entities.php @@ -173,6 +173,7 @@ '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 94ad059d4ce..1bc5d1551ae 100644 --- a/lang/bn/settings.php +++ b/lang/bn/settings.php @@ -207,6 +207,7 @@ '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 f671e1b4527..b490c8c9405 100644 --- a/lang/bs/entities.php +++ b/lang/bs/entities.php @@ -173,6 +173,7 @@ '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 c4d1eb136eb..3937c650f86 100644 --- a/lang/bs/settings.php +++ b/lang/bs/settings.php @@ -207,6 +207,7 @@ '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 108a38c2c50..edbfdbc4429 100644 --- a/lang/ca/entities.php +++ b/lang/ca/entities.php @@ -173,6 +173,7 @@ '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 a890b9809d4..2a2106eb35e 100644 --- a/lang/ca/settings.php +++ b/lang/ca/settings.php @@ -207,6 +207,7 @@ '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 d65d85ccb7e..7db62f7bb49 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -173,6 +173,7 @@ '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 a8c4036e87e..a7ab9927d8a 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -207,6 +207,7 @@ '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 e6df4317b1d..af1f6c43c92 100644 --- a/lang/cy/entities.php +++ b/lang/cy/entities.php @@ -173,6 +173,7 @@ '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 f4fbf0bba1b..a0519cccf0f 100644 --- a/lang/cy/settings.php +++ b/lang/cy/settings.php @@ -207,6 +207,7 @@ '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 ecda8a8cf8a..0aa35cc5db4 100644 --- a/lang/da/entities.php +++ b/lang/da/entities.php @@ -173,6 +173,7 @@ '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 1edf10d0ec9..fb5a1c958ab 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -207,6 +207,7 @@ '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 52f9b7acad1..db5d070f444 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -173,6 +173,7 @@ '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 64af973a46f..691ee2ee68b 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -207,6 +207,7 @@ '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 21e850bbf9c..40f6e76a13f 100644 --- a/lang/de/validation.php +++ b/lang/de/validation.php @@ -106,7 +106,7 @@ '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 16502051cee..708397e7e11 100644 --- a/lang/de_informal/entities.php +++ b/lang/de_informal/entities.php @@ -173,6 +173,7 @@ '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 @@ '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 4df17078320..dcbe16c9308 100644 --- a/lang/de_informal/notifications.php +++ b/lang/de_informal/notifications.php @@ -11,8 +11,8 @@ '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 97ad607d6bc..93ed1902fb5 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -76,7 +76,7 @@ '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 @@ '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 d693adecd83..5dcbd5102ff 100644 --- a/lang/de_informal/validation.php +++ b/lang/de_informal/validation.php @@ -106,7 +106,7 @@ '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 3779cd18a61..b551d1a86cb 100644 --- a/lang/el/entities.php +++ b/lang/el/entities.php @@ -173,6 +173,7 @@ '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 6ec5c4fddeb..605b8b40e01 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -207,6 +207,7 @@ '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 e652d4dc006..a8ec3b10ad2 100644 --- a/lang/es/entities.php +++ b/lang/es/entities.php @@ -173,6 +173,7 @@ '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 bfd3ce1cfe4..516480b6440 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -207,6 +207,7 @@ '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 1ae092de1ee..2dc1bd64801 100644 --- a/lang/es_AR/entities.php +++ b/lang/es_AR/entities.php @@ -173,6 +173,7 @@ '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 90f43a6f268..3b82d0fb35c 100644 --- a/lang/es_AR/settings.php +++ b/lang/es_AR/settings.php @@ -208,6 +208,7 @@ '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 323985b2d4a..4b7be605ac5 100644 --- a/lang/et/entities.php +++ b/lang/et/entities.php @@ -173,6 +173,7 @@ '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 bc5a7794e87..fd00da9e3d2 100644 --- a/lang/et/settings.php +++ b/lang/et/settings.php @@ -207,6 +207,7 @@ '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 cc51012d50f..4d9e7cb064a 100644 --- a/lang/eu/entities.php +++ b/lang/eu/entities.php @@ -173,6 +173,7 @@ '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 0f764dccbd2..dd346d1c9c9 100644 --- a/lang/eu/settings.php +++ b/lang/eu/settings.php @@ -207,6 +207,7 @@ '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 d7c0cc1c067..2abcff5768f 100644 --- a/lang/fa/entities.php +++ b/lang/fa/entities.php @@ -173,6 +173,7 @@ '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 2fa11511838..21ad9624015 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -207,6 +207,7 @@ '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 c64621f0d12..dd2ad5e69d3 100644 --- a/lang/fi/entities.php +++ b/lang/fi/entities.php @@ -173,6 +173,7 @@ '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 adc47fe2d87..3aec0188670 100644 --- a/lang/fi/settings.php +++ b/lang/fi/settings.php @@ -207,6 +207,7 @@ '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 fab11a83bfc..61ca0ee3e60 100644 --- a/lang/fr/auth.php +++ b/lang/fr/auth.php @@ -12,9 +12,9 @@ // 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 d650fc83058..fae7480b11c 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -173,6 +173,7 @@ '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 8c6c57f33aa..664184a7d98 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -207,6 +207,7 @@ '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 d8b264d8b68..38ffc95cff0 100644 --- a/lang/he/entities.php +++ b/lang/he/entities.php @@ -173,6 +173,7 @@ '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 46150081aa6..b8f20481328 100644 --- a/lang/he/settings.php +++ b/lang/he/settings.php @@ -207,6 +207,7 @@ '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 99df50ea33c..ff51c1b2f6f 100644 --- a/lang/hr/entities.php +++ b/lang/hr/entities.php @@ -173,6 +173,7 @@ '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 0692c8d7a13..60b2485efb1 100644 --- a/lang/hr/settings.php +++ b/lang/hr/settings.php @@ -207,6 +207,7 @@ '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 570120dfa99..707de62d871 100644 --- a/lang/hu/entities.php +++ b/lang/hu/entities.php @@ -173,6 +173,7 @@ '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 53b1cdcc424..aaccbd35f4c 100644 --- a/lang/hu/settings.php +++ b/lang/hu/settings.php @@ -207,6 +207,7 @@ '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 2f932992571..d978a3d19b1 100644 --- a/lang/id/entities.php +++ b/lang/id/entities.php @@ -173,6 +173,7 @@ '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 8bdd99e6890..fe3289ffa46 100644 --- a/lang/id/settings.php +++ b/lang/id/settings.php @@ -207,6 +207,7 @@ '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 4308835c7d8..0d9a6fc1eb7 100644 --- a/lang/is/entities.php +++ b/lang/is/entities.php @@ -173,6 +173,7 @@ '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 b1f21ac10b8..877e0dfa8ce 100644 --- a/lang/is/settings.php +++ b/lang/is/settings.php @@ -207,6 +207,7 @@ '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 eb9868973e5..1cce5fcd54f 100644 --- a/lang/it/entities.php +++ b/lang/it/entities.php @@ -173,6 +173,7 @@ '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 2b5819b2aa7..9a272db33d9 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -207,6 +207,7 @@ '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 551a2e49672..57703997f3f 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -173,6 +173,7 @@ '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 378e3a7748e..a3cbf696a18 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -207,6 +207,7 @@ '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 74c50be3b2f..5501d2bc229 100644 --- a/lang/ka/entities.php +++ b/lang/ka/entities.php @@ -173,6 +173,7 @@ '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 c4d1eb136eb..3937c650f86 100644 --- a/lang/ka/settings.php +++ b/lang/ka/settings.php @@ -207,6 +207,7 @@ '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 44809d99ff4..c54d6c7dc05 100644 --- a/lang/ko/entities.php +++ b/lang/ko/entities.php @@ -173,6 +173,7 @@ '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 97af673c6af..9aabe5c2a81 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -207,6 +207,7 @@ '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 74c50be3b2f..5501d2bc229 100644 --- a/lang/ku/entities.php +++ b/lang/ku/entities.php @@ -173,6 +173,7 @@ '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 c4d1eb136eb..3937c650f86 100644 --- a/lang/ku/settings.php +++ b/lang/ku/settings.php @@ -207,6 +207,7 @@ '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 6c4472d85a7..f6610a22a7d 100644 --- a/lang/lt/entities.php +++ b/lang/lt/entities.php @@ -173,6 +173,7 @@ '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 f797e567ec1..23dd38d50e6 100644 --- a/lang/lt/settings.php +++ b/lang/lt/settings.php @@ -207,6 +207,7 @@ '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 5f921048e52..f0f32ab2174 100644 --- a/lang/lv/entities.php +++ b/lang/lv/entities.php @@ -173,6 +173,7 @@ '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 9dc6bf402bd..0e11ebd65d6 100644 --- a/lang/lv/settings.php +++ b/lang/lv/settings.php @@ -207,6 +207,7 @@ '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 cf0fdffded0..a67c35e1748 100644 --- a/lang/nb/entities.php +++ b/lang/nb/entities.php @@ -173,6 +173,7 @@ '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 5fcaaaca6c1..5e779beead5 100644 --- a/lang/nb/settings.php +++ b/lang/nb/settings.php @@ -207,6 +207,7 @@ '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 88b202deed3..4d9f78ea504 100644 --- a/lang/ne/entities.php +++ b/lang/ne/entities.php @@ -173,6 +173,7 @@ '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 dbc7d8e9fc9..f52bcd42026 100644 --- a/lang/ne/settings.php +++ b/lang/ne/settings.php @@ -207,6 +207,7 @@ '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 b9be1f42b3c..c39beef2de6 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -173,6 +173,7 @@ '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 c8d071119ff..b82c52debba 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -207,6 +207,7 @@ '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 4c75d501986..4f79f3d7518 100644 --- a/lang/nn/entities.php +++ b/lang/nn/entities.php @@ -173,6 +173,7 @@ '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 e4b6e6af93d..b6c322c9c34 100644 --- a/lang/nn/settings.php +++ b/lang/nn/settings.php @@ -207,6 +207,7 @@ '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 f3ad807af29..88e4344de40 100644 --- a/lang/pl/entities.php +++ b/lang/pl/entities.php @@ -173,6 +173,7 @@ '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 775d4f25d2c..98201406e7b 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -207,6 +207,7 @@ '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 e882c79d001..709b8466aa7 100644 --- a/lang/pt/entities.php +++ b/lang/pt/entities.php @@ -173,6 +173,7 @@ '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 a59335b7cee..47fecbcbfee 100644 --- a/lang/pt/settings.php +++ b/lang/pt/settings.php @@ -207,6 +207,7 @@ '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 b5fd974aa20..0580542b49b 100644 --- a/lang/pt_BR/auth.php +++ b/lang/pt_BR/auth.php @@ -89,7 +89,7 @@ '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 5a948c8dcd8..0211f45fa7c 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -173,6 +173,7 @@ '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 97b434727f8..9b7c6a7d4f9 100644 --- a/lang/pt_BR/settings.php +++ b/lang/pt_BR/settings.php @@ -207,6 +207,7 @@ '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 ac8c9d32609..aa18d09fba7 100644 --- a/lang/ro/entities.php +++ b/lang/ro/entities.php @@ -173,6 +173,7 @@ '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 d65a8e0714f..117c4ca611a 100644 --- a/lang/ro/settings.php +++ b/lang/ro/settings.php @@ -207,6 +207,7 @@ '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 d353b7a7640..d9ac1685ba9 100644 --- a/lang/ru/activities.php +++ b/lang/ru/activities.php @@ -85,12 +85,12 @@ '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 fa9969c32af..bf77a999df3 100644 --- a/lang/ru/common.php +++ b/lang/ru/common.php @@ -30,8 +30,8 @@ '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 d26f7edfe08..da661559bd9 100644 --- a/lang/ru/editor.php +++ b/lang/ru/editor.php @@ -149,7 +149,7 @@ 'url' => 'URL-адрес', 'text_to_display' => 'Текст для отображения', 'title' => 'Заголовок', - 'browse_links' => 'Browse links', + 'browse_links' => 'Просмотр ссылки', 'open_link' => 'Открыть ссылку', 'open_link_in' => 'Открыть ссылку в...', 'open_link_current' => 'В текущем окне', @@ -166,8 +166,8 @@ '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 bf224c10a91..28d096fe802 100644 --- a/lang/ru/entities.php +++ b/lang/ru/entities.php @@ -51,18 +51,18 @@ '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 @@ '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 @@ '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 96e853723b7..ea885d910b9 100644 --- a/lang/ru/notifications.php +++ b/lang/ru/notifications.php @@ -12,7 +12,7 @@ '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 b61b252c8dc..2dcd20b11bb 100644 --- a/lang/ru/preferences.php +++ b/lang/ru/preferences.php @@ -23,7 +23,7 @@ '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 76a2eebbf1e..7bf5832a369 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -82,28 +82,28 @@ '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 @@ '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 ce94faa3b84..6d011cff10c 100644 --- a/lang/ru/validation.php +++ b/lang/ru/validation.php @@ -105,11 +105,11 @@ '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 eb52545357c..48b662bf388 100644 --- a/lang/sk/entities.php +++ b/lang/sk/entities.php @@ -173,6 +173,7 @@ '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 67671f6f82c..e18801ff467 100644 --- a/lang/sk/settings.php +++ b/lang/sk/settings.php @@ -207,6 +207,7 @@ '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 00ac991811e..86a43132ef6 100644 --- a/lang/sl/entities.php +++ b/lang/sl/entities.php @@ -173,6 +173,7 @@ '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 947621389f4..87c1e8e6db2 100644 --- a/lang/sl/settings.php +++ b/lang/sl/settings.php @@ -207,6 +207,7 @@ '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 74c50be3b2f..5501d2bc229 100644 --- a/lang/sq/entities.php +++ b/lang/sq/entities.php @@ -173,6 +173,7 @@ '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 c4d1eb136eb..3937c650f86 100644 --- a/lang/sq/settings.php +++ b/lang/sq/settings.php @@ -207,6 +207,7 @@ '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 151edee40fa..8f9c40e91a3 100644 --- a/lang/sr/entities.php +++ b/lang/sr/entities.php @@ -173,6 +173,7 @@ '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 f6c86827e69..3453bc344b3 100644 --- a/lang/sr/settings.php +++ b/lang/sr/settings.php @@ -207,6 +207,7 @@ '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 680e0908aa7..94fc23f501a 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -173,6 +173,7 @@ '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 773c4bff35e..47f602b8df8 100644 --- a/lang/sv/settings.php +++ b/lang/sv/settings.php @@ -207,6 +207,7 @@ '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 00000000000..6d84813d6ac --- /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 00000000000..c00b29bcd88 --- /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 00000000000..9a8b4fcc5df --- /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 00000000000..2931d2bc768 --- /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 00000000000..4af059bf19c --- /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 00000000000..1f0f30ec6ff --- /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 00000000000..61f1ed82c76 --- /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 00000000000..661c83fdb25 --- /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 00000000000..c29e7d0d9ce --- /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 00000000000..3429c776394 --- /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 00000000000..56d57fc2669 --- /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 00000000000..1b90d2a7196 --- /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 00000000000..347eeb36c9e --- /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 74c50be3b2f..5501d2bc229 100644 --- a/lang/tk/entities.php +++ b/lang/tk/entities.php @@ -173,6 +173,7 @@ '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 c4d1eb136eb..3937c650f86 100644 --- a/lang/tk/settings.php +++ b/lang/tk/settings.php @@ -207,6 +207,7 @@ '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 39feb7e3917..3d271ecb52f 100644 --- a/lang/tr/entities.php +++ b/lang/tr/entities.php @@ -173,6 +173,7 @@ '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 a33d3e0ac04..af8d2c6494b 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -207,6 +207,7 @@ '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 cf79b530cff..6bf7b241ef1 100644 --- a/lang/uk/entities.php +++ b/lang/uk/entities.php @@ -173,6 +173,7 @@ '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 afeb2c48928..9706b328b3b 100644 --- a/lang/uk/settings.php +++ b/lang/uk/settings.php @@ -207,6 +207,7 @@ '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 e35a485b65d..d3fdb594421 100644 --- a/lang/uz/entities.php +++ b/lang/uz/entities.php @@ -173,6 +173,7 @@ '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 259aee71a3f..0dbf8351e83 100644 --- a/lang/uz/settings.php +++ b/lang/uz/settings.php @@ -207,6 +207,7 @@ '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 f538f993ca2..fc1ca6d5620 100644 --- a/lang/vi/entities.php +++ b/lang/vi/entities.php @@ -173,6 +173,7 @@ '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 f5f2377c81b..c0bca56914c 100644 --- a/lang/vi/settings.php +++ b/lang/vi/settings.php @@ -207,6 +207,7 @@ '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 c4ec0414dab..9d57eeb5719 100644 --- a/lang/zh_CN/entities.php +++ b/lang/zh_CN/entities.php @@ -173,6 +173,7 @@ '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 e53e67aba32..86dd680b02c 100644 --- a/lang/zh_CN/settings.php +++ b/lang/zh_CN/settings.php @@ -207,6 +207,7 @@ '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 46bbe62f973..08886013c43 100644 --- a/lang/zh_TW/entities.php +++ b/lang/zh_TW/entities.php @@ -173,6 +173,7 @@ '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 65778f77ca3..fd5b088f5f8 100644 --- a/lang/zh_TW/settings.php +++ b/lang/zh_TW/settings.php @@ -208,6 +208,7 @@ '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 223/398] 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 def952c52e8..c68d262580b 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 d301826c9c6..9e4173cc798 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 c1420cb1933..0d799d0a78a 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 fad9ef1e83f..b86fb08e84f 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 d60eab71101..fde4aad7141 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 30d4d17891f..e9e5d197b43 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 a079fa63915..e39d72ce10c 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 224/398] 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 d23c2361004..ea32976a887 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 3937c650f86..3ccf15e7239 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -364,6 +364,7 @@ '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 225/398] 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 180e6d54501..d3e8467fe09 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 5ff2d14a5e0..06a6de276d2 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 80810fff170..2ffebc36a77 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 226/398] 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 00abe0c6d2f..2702b5486e2 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 227/398] 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 57f9a80ae18..b63c57ada74 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 ed70bf6996e..eab8203a0b2 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 e4b5bf2dce6..f5d3699fcbf 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 228/398] 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 b63c57ada74..e11d6bd5e83 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 f5d3699fcbf..28050571ede 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 229/398] 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 6f026ca1895..03e55ce64e8 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 890d5b325fe..f86482fdc2d 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 3b4d5b495a8..f8525bc7c69 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 00000000000..e04554f34b9 --- /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 230/398] 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 2d197ccc27a..7d87d9055b6 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 b0bf2f180bc..cff86a74a2b 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 2702b5486e2..f666657038b 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 231/398] 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 ab54bdb31d5..a30c86fcc71 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 00000000000..f7e7298e829 --- /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 232/398] 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 00000000000..e3cfcbb6344 --- /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 1c9d7ecf692..0e714e70fc2 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 2e4883d88e6..0e01b301c48 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 a3ada5c89f6..99d3b96d471 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 15cc3cbbeb8..b835614ab3b 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 16be32fb39b..5ea520d733d 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 233/398] 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 e69f97848ac..6be19c29371 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 e46dcc312ad..58a4de1dcff 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 234/398] 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 95a7234694a..4aba2a22f67 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 a30c86fcc71..c18c4b4ca74 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 00000000000..e6bb244f49a --- /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 235/398] 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 8e7219d6749..c92a16834ed 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 00000000000..9450638f57f --- /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 e18ac515f51..465d44d3136 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 73e6557527f..4344b234d60 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -11,7 +11,7 @@ class="flex-container-column flex-fill flex"> {{--
    --}} - +
    @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 236/398] 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 cff86a74a2b..840c5e8abae 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 35cc073a0ba..6588b7e10cc 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 0dbf9b94ed7..8c81d13ac5b 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 237/398] 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 6588b7e10cc..860f0392525 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 238/398] 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 f8525bc7c69..4dab455fe9b 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 cf5ec4ad151..bd86eca7e92 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.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.left = targetLeft + 'px'; + 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 239/398] 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 bd86eca7e92..95f0af11c50 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 0580d7377c9..4c05de0a8fb 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 240/398] 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 9c524dff057..68a80d2e100 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 95f0af11c50..ec8e9772b04 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 241/398] 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 | 49 +++++++++++-------- resources/js/wysiwyg/utils/selection.ts | 25 ++++++++++ 11 files changed, 114 insertions(+), 34 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index dc0ea211f59..59fb2acdea7 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 1b378b4a010..36badba977b 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 36e2db5470a..2904867f072 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 0329aed6f4e..0304fe4ad37 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 239c49a8c30..a7b04d47617 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 b5c83adddce..0311e8b313b 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 aa0d5d61129..5c7b81b7a5f 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 157fe79de1d..00e33c115e8 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 c451a45085d..984e7701675 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 a5f06f147d2..fbc11af490a 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 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); - } - - $openCodeEditorForNode(editor, codeBlock); - $selectSingleNode(codeBlock); - }); + const codeBlock = $createCodeBlockNode(); + + const codeLines = []; + const selectionNodes = $getBlockElementNodesInSelection(selection); + for (const node of selectionNodes) { + codeLines.push(node.getTextContent()); + } + codeBlock.setCode(codeLines.join('\n')); + + 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 28050571ede..e6815e25022 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 242/398] 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 a7b04d47617..2a1b698b123 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 10ff0fc6699..48fa2faf8b0 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 3deb9dfb6e9..cdbaba6fbbf 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 243/398] 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 2904867f072..73ce0edbb11 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 d9d83562c29..5f2ef9a25bd 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 6e5e5416fa0..7e2d93ad9e3 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 e6815e25022..77aa902f060 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 244/398] 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 6fc861312d4..86bae5f5b63 100644 --- a/app/Config/database.php +++ b/app/Config/database.php @@ -81,7 +81,6 @@ '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 8295febc1c1..53ae7900408 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 @@ class DrawioImageController extends Controller { public function __construct( - protected ImageRepo $imageRepo + protected ImageRepo $imageRepo, + protected PageQueries $pageQueries, ) { } @@ -53,16 +55,18 @@ public function list(Request $request, ImageResizer $resizer) */ 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 908322be07f..ca60f9f8e19 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 @@ class GalleryImageController extends Controller { public function __construct( - protected ImageRepo $imageRepo + protected ImageRepo $imageRepo, + protected PageQueries $pageQueries, ) { } @@ -56,20 +58,26 @@ public function create(Request $request) $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 c4168a77e94..6a72e4c30e4 100644 --- a/app/Uploads/Controllers/ImageGalleryApiController.php +++ b/app/Uploads/Controllers/ImageGalleryApiController.php @@ -75,6 +75,7 @@ public function create(Request $request) $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 7f26f8689d2..b9701b11577 100644 --- a/tests/Activity/CommentMentionTest.php +++ b/tests/Activity/CommentMentionTest.php @@ -117,7 +117,7 @@ public function test_notification_limited_to_those_with_view_permissions() $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 464d62683ad..6a26b403245 100644 --- a/tests/Api/ContentPermissionsApiTest.php +++ b/tests/Api/ContentPermissionsApiTest.php @@ -39,7 +39,7 @@ public function test_read_endpoint_shows_expected_detail() $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 @@ public function test_update_can_set_fallback_permissions() 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 07c20c83416..09dba84f548 100644 --- a/tests/Api/ImageGalleryApiTest.php +++ b/tests/Api/ImageGalleryApiTest.php @@ -154,6 +154,34 @@ public function test_create_fails_if_uploaded_to_not_visible_or_not_exists() $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 75c6c1b3851..27a339fbe1f 100644 --- a/tests/Commands/RegeneratePermissionsCommandTest.php +++ b/tests/Commands/RegeneratePermissionsCommandTest.php @@ -16,7 +16,7 @@ public function test_regen_permissions_command() $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 642945d4388..27c83e212b6 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 @@ public function test_404_page_shows_visible_content_within_non_visible_parent() $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 c9ae309196e..9b12ee985b3 100644 --- a/tests/Helpers/PermissionsProvider.php +++ b/tests/Helpers/PermissionsProvider.php @@ -101,8 +101,9 @@ public function setEntityPermissions(Entity $entity, array $actions = [], array $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 55761e08c58..fa89d365c0a 100644 --- a/tests/Permissions/Scenarios/EntityRolePermissionsTest.php +++ b/tests/Permissions/Scenarios/EntityRolePermissionsTest.php @@ -29,8 +29,8 @@ public function test_03_same_level_conflicting() $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 @@ public function test_20_inherit_allow() $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 @@ public function test_21_inherit_deny() $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 @@ public function test_22_same_level_conflict_inherit() $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 @@ public function test_30_child_inherit_override_allow() $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 @@ public function test_31_child_inherit_override_deny() $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 @@ public function test_40_multi_role_inherit_conflict_override_deny() $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 @@ public function test_41_multi_role_inherit_conflict_retain_allow() $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 @@ public function test_50_role_override_allow() { [$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 @@ public function test_51_role_override_deny() { [$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 @@ public function test_60_inherited_role_override_allow() [$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 @@ public function test_61_inherited_role_override_deny() [$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 @@ public function test_62_inherited_role_override_deny_on_own() [$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 @@ public function test_70_multi_role_inheriting_deny() $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 @@ public function test_71_multi_role_inheriting_deny_on_own() $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 @@ public function test_75_multi_role_inherited_deny_via_parent() $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 @@ public function test_76_multi_role_inherited_deny_via_parent_on_own() $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 @@ public function test_80_fallback_override_allow() $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 @@ public function test_81_fallback_override_deny() $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 @@ public function test_84_fallback_override_allow_multi_role() $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 @@ public function test_85_fallback_override_deny_multi_role() $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 @@ public function test_86_fallback_override_allow_inherit() $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 @@ public function test_87_fallback_override_deny_inherit() $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 @@ public function test_88_fallback_override_allow_multi_role_inherit() $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 @@ public function test_89_fallback_override_deny_multi_role_inherit() $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 @@ public function test_90_fallback_overrides_parent_entity_role_deny() $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 @@ public function test_91_fallback_overrides_parent_entity_role_inherit() $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 e6fc7a6a3c5..5b0f5353a7f 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -6,7 +6,6 @@ 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 @@ public function test_public_view_can_take_on_other_roles() $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 d5b3f60880e..faa2a0bfb11 100644 --- a/tests/Uploads/DrawioTest.php +++ b/tests/Uploads/DrawioTest.php @@ -72,6 +72,30 @@ public function test_drawing_base64_upload() $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 1bccee2c395..ac7bf31e4c4 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -36,6 +36,45 @@ public function test_image_upload() ]); } + 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 245/398] 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 a7f129f71d4..64532de175c 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 ba85f99fbd0..f7310438122 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -4,6 +4,7 @@ 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 @@ public function destroy(Request $request, int $id) */ 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 4362fc02958..e9344a3d477 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -99,6 +99,8 @@ '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 920c3c1bc02..d03024a89d6 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -264,11 +264,9 @@ '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 64d45f50361..8a4a8bcce79 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -71,21 +71,35 @@ class="button outline">{{ trans('settings.users_mfa_configure') }}
  • - @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 2571da2f3a2..3e3b5aef37d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -251,7 +251,7 @@ 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 00000000000..ea05869cb0e --- /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()); + } +} From 0cd773a5d3d07b1299660c22a3f65456b6aa28d0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 17 May 2026 18:33:23 +0100 Subject: [PATCH 246/398] CSP Headers: Review of #6071 - Removed extra non-needed docs in repo - Tweaked some wording. - Added extra test scenarios. - Added options to phpunit default env. - Added auto-quote-handling for unsafe-inline CSS rule. For #6033 --- .env.example.complete | 5 +++-- app/Config/app.php | 3 ++- app/Util/CspService.php | 10 ++++++++- dev/docs/development.md | 42 ------------------------------------ phpunit.xml | 2 ++ readme.md | 1 - tests/SecurityHeaderTest.php | 35 ++++++++++++++++++++++++++++-- 7 files changed, 49 insertions(+), 49 deletions(-) diff --git a/.env.example.complete b/.env.example.complete index 6a7f9db6529..ed0cac5f473 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -402,9 +402,10 @@ ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtub ALLOWED_CSS_SOURCES=null # A list of sources/hostnames that can be loaded as image content within BookStack. -# Space separated if multiple. BookStack host domain is auto-inferred. +# Space separated if multiple. BookStack host domain is auto-inferred, in addition to +# data and blob images, due to their use for various functionality. # Defaults to a permissive set if not provided. -# Example: ALLOWED_IMAGE_SOURCES="https://images.example.com data:" +# Example: ALLOWED_IMAGE_SOURCES="https://images.example.com" ALLOWED_IMAGE_SOURCES=null # A list of the sources/hostnames that can be reached by application SSR calls. diff --git a/app/Config/app.php b/app/Config/app.php index 5536e9abdd4..12f285ea9e1 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -78,7 +78,8 @@ 'css_sources' => env('ALLOWED_CSS_SOURCES', null), // A list of sources/hostnames that can be loaded as image content within BookStack. - // Space separated if multiple. BookStack host domain is auto-inferred. + // Space separated if multiple. BookStack host domain is auto-inferred, in addition to + // data and blob images, due to their use for various functionality. // If not set, a permissive default set is used to reduce potential breakage. 'image_sources' => env('ALLOWED_IMAGE_SOURCES', null), diff --git a/app/Util/CspService.php b/app/Util/CspService.php index a0e1faadf95..68c544ff09d 100644 --- a/app/Util/CspService.php +++ b/app/Util/CspService.php @@ -175,6 +175,14 @@ protected function getAllowedStyleSources(): array $sources = array_filter(explode(' ', $configured)); array_unshift($sources, "'self'"); + // Ensure 'unsafe-inline' is quoted if present + // This is done as attempting to pass this in env values with quotes can either + // be awkward or cause issues. + $unsafeInlineIndex = array_search('unsafe-inline', $sources, true); + if ($unsafeInlineIndex !== false) { + $sources[$unsafeInlineIndex] = "'unsafe-inline'"; + } + return array_values(array_unique($sources)); } @@ -195,7 +203,7 @@ protected function getAllowedImageSources(): array if (is_string($configured)) { $sources = array_filter(explode(' ', $configured)); - array_unshift($sources, "'self'"); + array_unshift($sources, "'self'", 'blob:', 'data:'); return array_values(array_unique($sources)); } diff --git a/dev/docs/development.md b/dev/docs/development.md index 16f168f9cda..2c73a0256c5 100644 --- a/dev/docs/development.md +++ b/dev/docs/development.md @@ -31,48 +31,6 @@ BookStack has a large suite of PHP tests to cover application functionality. We For details about setting-up, running and writing tests please see the [php-testing.md document](php-testing.md). -## Content Security Policy Controls - -BookStack enforces a Content Security Policy (CSP) response header to reduce risk from injected content and untrusted embeds. - -For backward compatibility, image and CSS controls are intentionally permissive by default, but can be tightened via environment options. - -### Related Environment Options - -These values are defined in `.env.example.complete`: - -- `ALLOWED_CSS_SOURCES` - - Controls allowed `style-src` sources. - - Defaults to a permissive fallback if unset. -- `ALLOWED_IMAGE_SOURCES` - - Controls allowed `img-src` sources. - - Defaults to a permissive fallback if unset. - -Values should be space-separated source expressions. - -### Example Configurations - -Allow Google Fonts CSS and local styles only: - -```bash -ALLOWED_CSS_SOURCES="https://fonts.googleapis.com" -``` - -Allow local images, embedded data images, and a dedicated image CDN: - -```bash -ALLOWED_IMAGE_SOURCES="data: https://images.example.com" -``` - -### Tightening Guidance - -When hardening a deployment: - -1. Start with defaults to avoid unexpected breakage. -2. Set explicit `ALLOWED_CSS_SOURCES` and `ALLOWED_IMAGE_SOURCES` values for the domains you actually use. -3. Test key workflows (editor, page display, theme assets, external embeds) and browser console CSP warnings. -4. Remove unnecessary protocols and hosts over time. - ## Code Standards We use tools to manage code standards and formatting within the project. If submitting a PR, formatting as per our project standards would help for clarity but don't worry too much about using/understanding these tools as we can always address issues at a later stage when they're picked up by our automated tools. diff --git a/phpunit.xml b/phpunit.xml index 94fc002b704..52c5e7de8e9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,6 +18,8 @@ + + diff --git a/readme.md b/readme.md index e231ad9f0ba..e9e5d197b43 100644 --- a/readme.md +++ b/readme.md @@ -100,7 +100,6 @@ Big thanks to these companies for supporting the project. ## 🛠️ Development & Testing Please see our [development docs](dev/docs/development.md) for full details regarding work on the BookStack source code. -For details on Content Security Policy controls (including image and CSS source options), see the **Content Security Policy Controls** section in the [development docs](dev/docs/development.md). If you're just looking to customize or extend your own BookStack instance, take a look at our [Hacking BookStack documentation page](https://www.bookstackapp.com/docs/admin/hacking-bookstack/) for details on various options to achieve this without altering the BookStack source code. diff --git a/tests/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php index 126a85f238c..8badc39b9b3 100644 --- a/tests/SecurityHeaderTest.php +++ b/tests/SecurityHeaderTest.php @@ -153,6 +153,7 @@ public function test_frame_src_csp_header_drawio_host_includes_port_if_existing( public function test_style_src_csp_header_set_to_permissive_defaults_when_not_configured() { + config()->set('app.css_sources', null); $resp = $this->get('/'); $header = $this->getCspHeader($resp, 'style-src'); @@ -169,8 +170,29 @@ public function test_style_src_csp_header_can_be_overridden_by_config() $this->assertEquals("style-src 'self' https://fonts.example.com", $header); } + public function test_style_src_csp_header_unsafe_inline_value_will_be_auto_quoted() + { + config()->set('app.css_sources', 'unsafe-inline https://css.example.com'); + + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'style-src'); + + $this->assertEquals("style-src 'self' 'unsafe-inline' https://css.example.com", $header); + } + + public function test_style_src_can_be_blank_to_set_no_additions() + { + config()->set('app.css_sources', ''); + + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'style-src'); + + $this->assertEquals("style-src 'self'", $header); + } + public function test_img_src_csp_header_set_to_permissive_defaults_when_not_configured() { + config()->set('app.image_sources', null); $resp = $this->get('/'); $header = $this->getCspHeader($resp, 'img-src'); @@ -179,12 +201,21 @@ public function test_img_src_csp_header_set_to_permissive_defaults_when_not_conf public function test_img_src_csp_header_can_be_overridden_by_config() { - config()->set('app.image_sources', 'https://images.example.com data:'); + config()->set('app.image_sources', 'https://images.example.com'); + + $resp = $this->get('/'); + $header = $this->getCspHeader($resp, 'img-src'); + $this->assertEquals("img-src 'self' blob: data: https://images.example.com", $header); + } + + public function test_img_src_can_be_blank_to_set_no_additions() + { + config()->set('app.image_sources', ''); $resp = $this->get('/'); $header = $this->getCspHeader($resp, 'img-src'); - $this->assertEquals("img-src 'self' https://images.example.com data:", $header); + $this->assertEquals("img-src 'self' blob: data:", $header); } public function test_cache_control_headers_are_set_on_responses() From b5d3ba2726dc2f3792f580a8d00cf8d6bbe01126 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 May 2026 17:39:00 +0100 Subject: [PATCH 247/398] Testing: Added extra page edit test Added during investigation for #6062 Might as well leave in even though it does not trigger the cause for that particuluar issue. --- tests/Entity/PageTest.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index 1b2f3c9fe5a..0da76b8e92f 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -273,4 +273,29 @@ public function test_page_edit_without_update_permissions_but_with_view_redirect $resp->assertSessionHas('error', 'You do not have permission to access the requested page.'); } + + public function test_page_html_content_remains_stable_through_re_edit_and_does_not_create_revision() + { + $page = $this->entities->page(); + + $this->asEditor()->put($page->getUrl(), [ + 'name' => 'Stability Test', + 'html' => '
    a
    ', + ]); + $initialRevisionCount = $page->revisions()->count(); + $page->refresh(); + + // Get the page content from the edit view to ensure we're using the + // exact same content which would be loaded into the editor. + $editView = $this->get($page->getUrl('/edit')); + $htmlContentEncoded = $this->withHtml($editView)->getInnerHtml('textarea#html-editor'); + $htmlContent = html_entity_decode($htmlContentEncoded); + + $this->asEditor()->put($page->getUrl(), [ + 'name' => 'Stability Test', + 'html' => $htmlContent, + ]); + + $this->assertEquals($initialRevisionCount, $page->revisions()->count()); + } } From 5ebfa65a46a0ee0d1ef9c54140ac2bbd3dbd59e6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 May 2026 13:29:26 +0100 Subject: [PATCH 248/398] Testing: Changed ordering in tests to help prevent flaky test Think it would primariy use the created_at ordering based in the relation which could cause trouble in CI test environment. This better forces Id based ordering --- tests/Activity/WatchTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php index 8be09f890dc..dc78e894253 100644 --- a/tests/Activity/WatchTest.php +++ b/tests/Activity/WatchTest.php @@ -218,10 +218,10 @@ public function test_notify_comment_replies() $notifications = Notification::fake(); - $this->actingAs($editor)->post("/comment/{$entities['page']->id}", [ + $resp = $this->actingAs($editor)->post("/comment/{$entities['page']->id}", [ 'html' => '

    My new comment

    ' ]); - $comment = $entities['page']->comments()->orderBy('id', 'desc')->first(); + $comment = $entities['page']->comments()->reorder('id', 'desc')->first(); $this->asAdmin()->post("/comment/{$entities['page']->id}", [ 'html' => '

    My new comment response

    ', From 49aa0250121f177c1828cc8c984c4e04cfeeba35 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 May 2026 17:56:48 +0100 Subject: [PATCH 249/398] Page Editor: Started contents view in toolbox Added visual system, not yet added on-click logic. Related to #4218 --- lang/en/entities.php | 3 + resources/icons/contents.svg | 1 + resources/js/components/index.ts | 1 + resources/js/components/toolbox-contents.ts | 132 ++++++++++++++++++ resources/js/services/vdom.ts | 4 + resources/sass/_lists.scss | 10 ++ .../pages/parts/editor-toolbox.blade.php | 3 + .../show-sidebar-section-page-nav.blade.php | 4 +- .../pages/parts/toolbox-contents.blade.php | 9 ++ 9 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 resources/icons/contents.svg create mode 100644 resources/js/components/toolbox-contents.ts create mode 100644 resources/views/pages/parts/toolbox-contents.blade.php diff --git a/lang/en/entities.php b/lang/en/entities.php index 5501d2bc229..58c00ec4b27 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Page Tags', 'chapter_tags' => 'Chapter Tags', 'book_tags' => 'Book Tags', diff --git a/resources/icons/contents.svg b/resources/icons/contents.svg new file mode 100644 index 00000000000..3a315b431cc --- /dev/null +++ b/resources/icons/contents.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 736d93f0595..688877227f2 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -58,6 +58,7 @@ export {Tabs} from './tabs'; export {TagManager} from './tag-manager'; export {TemplateManager} from './template-manager'; export {ToggleSwitch} from './toggle-switch'; +export {ToolboxContents} from './toolbox-contents'; export {TriLayout} from './tri-layout'; export {UserSelect} from './user-select'; export {WebhookEvents} from './webhook-events'; diff --git a/resources/js/components/toolbox-contents.ts b/resources/js/components/toolbox-contents.ts new file mode 100644 index 00000000000..e09f2b5acf4 --- /dev/null +++ b/resources/js/components/toolbox-contents.ts @@ -0,0 +1,132 @@ +import {Component} from "./component"; +import {debounce} from "../services/util"; +import {EditorToolboxChangeEventData} from "./editor-toolbox"; +import {PageEditor} from "./page-editor"; +import {MarkdownEditor} from "./markdown-editor"; +import {WysiwygEditorTinymce} from "./wysiwyg-editor-tinymce"; +import {WysiwygEditor} from "./wysiwyg-editor"; +import {elem} from "../services/dom"; +import {patchDomFromDom} from "../services/vdom"; + +interface ToolboxContentHeader { + // The text shown for the header + text: string; + // The level/depth of the header + level: number; + // The index of the header relative to all other headers in the content + index: number; + // The id set for the header (if at all) + id: string; +} + +export class ToolboxContents extends Component { + protected container!: HTMLElement; + protected noneEl!: HTMLElement; + protected display!: HTMLElement; + protected isActive: boolean = false; + + setup() { + this.container = this.$el as HTMLLinkElement; + this.noneEl = this.$refs.none; + this.display = this.$refs.display; + + // Listen to when visible in the editor toolbox so we only update when visible + window.addEventListener('editor-toolbox-change', ((event: CustomEvent) => { + const tabName: string = event.detail.tab; + const isOpen = event.detail.open; + if (tabName === 'contents' && isOpen) { + if (this.isActive !== true) { + this.render(); + } + this.isActive = true; + } else { + this.isActive = false; + } + }) as EventListener); + + // Listen to content changes from the editor + const onContentChangeDebounced = debounce(this.onContentChange.bind(this), 500, false); + window.$events.listen('editor-html-change', onContentChangeDebounced); + window.$events.listen('editor-markdown-change', onContentChangeDebounced); + } + + protected onContentChange(): void { + if (!this.isActive) { + return; + } + this.render(); + } + + protected async render(): Promise { + const editorHtml = await this.getEditorHtml(); + const headers = this.parseHeadersFromHtml(editorHtml); + this.rebaseHeaders(headers); + + const headerDom = this.headersToDom(headers); + let displayChild = this.display.firstElementChild; + if (!displayChild) { + displayChild = document.createElement("ul"); + this.display.appendChild(displayChild); + } + patchDomFromDom(displayChild, headerDom); + + this.noneEl.hidden = headers.length > 0; + } + + protected async getEditorHtml(): Promise { + const pageEditorComponent = window.$components.first('page-editor') as PageEditor; + const editor = pageEditorComponent.getEditorComponent() as (MarkdownEditor|WysiwygEditorTinymce|WysiwygEditor); + return (await editor.getContent()).html; + } + + protected parseHeadersFromHtml(html: string): ToolboxContentHeader[] { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const headerNodes = doc.querySelectorAll('h1, h2, h3, h4, h5, h6'); + const headers: ToolboxContentHeader[] = []; + + for (let i = 0; i < headerNodes.length; i++) { + const headerNode = headerNodes[i]; + const level = Number(headerNode.tagName.replace('H', '')); + headers.push({ + text: headerNode.textContent, + level, + id: headerNode.id || '', + index: i, + }); + } + + return headers; + } + + protected rebaseHeaders(headers: ToolboxContentHeader[]): void { + if (headers.length === 0) { + return; + } + + do { + var minLevel = Math.min(...headers.map(h => h.level)); + if (minLevel > 1) { + for (const header of headers) { + header.level--; + } + } + } while (minLevel > 1); + } + + protected headersToDom(headers: ToolboxContentHeader[]): HTMLElement { + const headerItems = headers.map(header => { + return elem('li', { + 'data-level': String(header.level), + id: header.id, + class: `page-nav-item h${header.level}`, + }, [ + elem('a', {class: 'text-limit-lines-1 block', href: `#${header.id}`}, [header.text]), + elem('div', {class: 'link-background sidebar-page-nav-bullet'}), + ]); + }); + return elem('ul', { + class: 'sidebar-page-nav menu' + }, headerItems); + } +} \ No newline at end of file diff --git a/resources/js/services/vdom.ts b/resources/js/services/vdom.ts index c32f328f5e7..051b1a42abc 100644 --- a/resources/js/services/vdom.ts +++ b/resources/js/services/vdom.ts @@ -24,3 +24,7 @@ export function patchDomFromHtmlString(domTarget: Element, html: string): void { contentDom.innerHTML = html; getPatcher()(toVNode(domTarget), toVNode(contentDom)); } + +export function patchDomFromDom(domTarget: Element, domSource: Element): void { + getPatcher()(toVNode(domTarget), toVNode(domSource)); +} diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index d9a1195aa0d..249dca0d57e 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -93,6 +93,8 @@ list-style: none; @include mixins.margin(vars.$s, 0, vars.$m, vars.$xs); position: relative; + padding-inline: 0; + display: block; &:after { content: ''; display: block; @@ -153,6 +155,14 @@ } } } +.toolbox-tab-content .sidebar-page-nav { + .sidebar-page-nav-bullet { + box-shadow: 0 0 0 6px #FFFFFF; + } + li { + font-size: 1em; + } +} // Sidebar list .book-tree .sidebar-page-list { diff --git a/resources/views/pages/parts/editor-toolbox.blade.php b/resources/views/pages/parts/editor-toolbox.blade.php index 8b7a68a9aa7..8ef259b91e4 100644 --- a/resources/views/pages/parts/editor-toolbox.blade.php +++ b/resources/views/pages/parts/editor-toolbox.blade.php @@ -11,6 +11,7 @@ @if($comments->enabled()) @endif +
    @@ -37,4 +38,6 @@ @include('pages.parts.toolbox-comments') @endif + @include('pages.parts.toolbox-contents') +
    diff --git a/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php b/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php index 88db87e6483..d046a8ca141 100644 --- a/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php +++ b/resources/views/pages/parts/show-sidebar-section-page-nav.blade.php @@ -2,14 +2,14 @@ @endif \ No newline at end of file diff --git a/resources/views/pages/parts/toolbox-contents.blade.php b/resources/views/pages/parts/toolbox-contents.blade.php new file mode 100644 index 00000000000..6234cdf4392 --- /dev/null +++ b/resources/views/pages/parts/toolbox-contents.blade.php @@ -0,0 +1,9 @@ +
    +

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

    + +
    +

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

    + +
    +
    +
    \ No newline at end of file From c58eb918939c9ef24998e3da9379c29969d75f0b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 May 2026 12:12:38 +0100 Subject: [PATCH 250/398] Page Editor: Added contents view click logic Added jump-to-header logic for lexical WYSIWYG, and both codemirror & plaintext markdown editor windows. --- resources/js/components/toolbox-contents.ts | 22 ++++++++++++- resources/js/markdown/actions.ts | 31 +++++++++++++++++-- resources/js/markdown/common-events.ts | 5 +++ resources/js/markdown/inputs/codemirror.ts | 13 ++++++++ resources/js/markdown/inputs/interface.ts | 6 ++++ resources/js/markdown/inputs/textarea.ts | 14 +++++++++ resources/js/services/vdom.ts | 2 ++ .../js/wysiwyg/services/common-events.ts | 6 +++- resources/js/wysiwyg/utils/actions.ts | 13 +++++++- 9 files changed, 107 insertions(+), 5 deletions(-) diff --git a/resources/js/components/toolbox-contents.ts b/resources/js/components/toolbox-contents.ts index e09f2b5acf4..569b574ee37 100644 --- a/resources/js/components/toolbox-contents.ts +++ b/resources/js/components/toolbox-contents.ts @@ -48,6 +48,24 @@ export class ToolboxContents extends Component { const onContentChangeDebounced = debounce(this.onContentChange.bind(this), 500, false); window.$events.listen('editor-html-change', onContentChangeDebounced); window.$events.listen('editor-markdown-change', onContentChangeDebounced); + + // Listen to header click + this.container.addEventListener('click', (event) => { + const header = (event.target as HTMLElement).closest('li[data-index]'); + if (header instanceof HTMLElement) { + event.preventDefault(); + this.onHeaderSelect(header); + } + }); + } + + protected onHeaderSelect(headerElement: HTMLElement): void { + const id = headerElement.getAttribute('data-id') || ''; + const index = Number(headerElement.getAttribute('data-index') || ''); + window.$events.emit('editor::focus-heading', { + index, + id, + }); } protected onContentChange(): void { @@ -118,7 +136,9 @@ export class ToolboxContents extends Component { const headerItems = headers.map(header => { return elem('li', { 'data-level': String(header.level), - id: header.id, + 'data-index': String(header.index), + 'data-id': header.id, + id: `page-contents-${header.id}`, class: `page-nav-item h${header.level}`, }, [ elem('a', {class: 'text-limit-lines-1 block', href: `#${header.id}`}, [header.text]), diff --git a/resources/js/markdown/actions.ts b/resources/js/markdown/actions.ts index 36d21ab1dc6..ff1f0bc9435 100644 --- a/resources/js/markdown/actions.ts +++ b/resources/js/markdown/actions.ts @@ -184,14 +184,41 @@ export class Actions { } } - focus() { + /** + * Set user focus on the editor edit area. + */ + focus(): void { this.editor.input.focus(); } + /** + * Focus on the text for a specific header in the content. + */ + focusOnHeader(index: number): void { + const headerPattern = /^\s{0,3}(#+| { + const isHeader = headerPattern.test(content); + if (isHeader && !inCodeBoundary) { + currentIndex++; + if (currentIndex === index) { + this.editor.input.setSelection({from: range.to, to: range.to}, true); + return false; + } + } else if (codeBoundary.test(content)) { + inCodeBoundary = !inCodeBoundary; + } + }); + + this.focus(); + } + /** * Insert content into the editor. */ - insertContent(content: string) { + insertContent(content: string): void { this.#replaceSelection(content, content.length); } diff --git a/resources/js/markdown/common-events.ts b/resources/js/markdown/common-events.ts index 4bfc4bb4619..e8cbe561c8e 100644 --- a/resources/js/markdown/common-events.ts +++ b/resources/js/markdown/common-events.ts @@ -1,4 +1,5 @@ import {MarkdownEditor} from "./index.mjs"; +import {focusOnHeader} from "../wysiwyg/utils/actions"; export interface HtmlOrMarkdown { html: string; @@ -33,4 +34,8 @@ export function listenToCommonEvents(editor: MarkdownEditor): void { window.$events.listen('editor::focus', () => { editor.actions.focus(); }); + + window.$events.listen<{id: string, index: number}>('editor::focus-heading', ({index}) => { + editor.actions.focusOnHeader(index); + }); } diff --git a/resources/js/markdown/inputs/codemirror.ts b/resources/js/markdown/inputs/codemirror.ts index 827068238e0..8cc7f409cc5 100644 --- a/resources/js/markdown/inputs/codemirror.ts +++ b/resources/js/markdown/inputs/codemirror.ts @@ -102,6 +102,19 @@ export class CodemirrorInput implements MarkdownEditorInput { return {from: line.from, to: line.to}; } + forEachLine(callback: (lineNum: number, content: string, range: MarkdownEditorInputSelection) => false | void): void { + const docText = this.cm.state.doc; + let lineCount = 0; + for (const line of docText.iterLines()) { + lineCount++; + const lineInfo = docText.line(lineCount); + const result = callback(lineCount, line, {from: lineInfo.from, to: lineInfo.to}); + if (result === false) { + break; + } + } + } + /** * Dispatch changes to the editor. */ diff --git a/resources/js/markdown/inputs/interface.ts b/resources/js/markdown/inputs/interface.ts index 1f7474a5088..d39f7dc1418 100644 --- a/resources/js/markdown/inputs/interface.ts +++ b/resources/js/markdown/inputs/interface.ts @@ -74,6 +74,12 @@ export interface MarkdownEditorInput { */ searchForLineContaining(text: string): MarkdownEditorInputSelection|null; + /** + * Run the provided callback against each line of content within the input. + * Callback can return false to stop further processing. + */ + forEachLine(callback: (lineNum: number, content: string, range: MarkdownEditorInputSelection) => false|void): void; + /** * Tear down the input. */ diff --git a/resources/js/markdown/inputs/textarea.ts b/resources/js/markdown/inputs/textarea.ts index e0c3ac37cf2..c232a24bf46 100644 --- a/resources/js/markdown/inputs/textarea.ts +++ b/resources/js/markdown/inputs/textarea.ts @@ -214,6 +214,20 @@ export class TextareaInput implements MarkdownEditorInput { return null; } + forEachLine(callback: (lineNum: number, content: string, range: MarkdownEditorInputSelection) => false | void): void { + const lines = this.getText().split('\n'); + let lineStart = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineEnd = lineStart + line.length; + const result = callback(i + 1, line, {from: lineStart, to: lineEnd}); + if (result === false) { + break; + } + lineStart = lineEnd + 1; + } + } + setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void { this.input.selectionStart = selection.from; this.input.selectionEnd = selection.to; diff --git a/resources/js/services/vdom.ts b/resources/js/services/vdom.ts index 051b1a42abc..a83686778fe 100644 --- a/resources/js/services/vdom.ts +++ b/resources/js/services/vdom.ts @@ -1,6 +1,7 @@ import { init, attributesModule, + datasetModule, toVNode, } from 'snabbdom'; import {VNode} from "snabbdom/build/vnode"; @@ -14,6 +15,7 @@ function getPatcher(): vDomPatcher { patcher = init([ attributesModule, + datasetModule, ]); return patcher; diff --git a/resources/js/wysiwyg/services/common-events.ts b/resources/js/wysiwyg/services/common-events.ts index f7fc81cb3cf..9c6a91787cc 100644 --- a/resources/js/wysiwyg/services/common-events.ts +++ b/resources/js/wysiwyg/services/common-events.ts @@ -1,7 +1,7 @@ import {LexicalEditor} from "lexical"; import { appendHtmlToEditor, - focusEditor, + focusEditor, focusOnHeader, insertHtmlIntoEditor, prependHtmlToEditor, setEditorContentFromHtml @@ -41,6 +41,10 @@ export function listen(editor: LexicalEditor): void { focusEditor(editor); }); + window.$events.listen<{id: string, index: number}>('editor::focus-heading', ({index}) => { + focusOnHeader(editor, index); + }); + let changeFromLoading = true; editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { // Emit change event to component system (for draft detection) on actual user content change diff --git a/resources/js/wysiwyg/utils/actions.ts b/resources/js/wysiwyg/utils/actions.ts index 465d44d3136..f5b2db6aad4 100644 --- a/resources/js/wysiwyg/utils/actions.ts +++ b/resources/js/wysiwyg/utils/actions.ts @@ -1,6 +1,7 @@ import {$createParagraphNode, $getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical"; import {$generateHtmlFromNodes} from "@lexical/html"; -import {$getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes"; +import {$getAllNodesOfType, $getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes"; +import {$isHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { editor.update(() => { @@ -101,4 +102,14 @@ export function focusEditor(editor: LexicalEditor): void { }); editor.commitUpdates(); editor.focus(() => {}, {defaultSelection: "rootStart"}); +} + +export function focusOnHeader(editor: LexicalEditor, headerIndex: number): void { + editor.update(() => { + const headers = $getAllNodesOfType($isHeadingNode); + const target = headers[headerIndex]; + if (target) { + target.selectStart(); + } + }); } \ No newline at end of file From 1b9ec75903ec3308b9840ac9948dce5ee3c913f4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 09:48:20 +0100 Subject: [PATCH 251/398] Deps: Updated PHP package versions --- composer.lock | 387 ++++++++++++++++++++++++++------------------------ 1 file changed, 198 insertions(+), 189 deletions(-) diff --git a/composer.lock b/composer.lock index 4a56da48823..c0346a7b27c 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.379.8", + "version": "3.381.5", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "856ddf3d241c29132fe1eb946e112351ab043542" + "reference": "409208d62af0ddafbcb0af1a0bf514f5ffcaba92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/856ddf3d241c29132fe1eb946e112351ab043542", - "reference": "856ddf3d241c29132fe1eb946e112351ab043542", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/409208d62af0ddafbcb0af1a0bf514f5ffcaba92", + "reference": "409208d62af0ddafbcb0af1a0bf514f5ffcaba92", "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.379.8" + "source": "https://github.com/aws/aws-sdk-php/tree/3.381.5" }, - "time": "2026-04-27T19:13:21+00:00" + "time": "2026-05-20T18:16:01+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1179,16 +1179,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.10.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", + "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", "shasum": "" }, "require": { @@ -1206,8 +1206,9 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.3.2", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -1285,7 +1286,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.10.3" }, "funding": [ { @@ -1301,20 +1302,20 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-05-20T22:59:19+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", "shasum": "" }, "require": { @@ -1322,7 +1323,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -1368,7 +1369,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.4.1" }, "funding": [ { @@ -1384,20 +1385,20 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-05-20T22:57:30+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.9.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", "shasum": "" }, "require": { @@ -1412,9 +1413,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", + "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1485,7 +1486,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" + "source": "https://github.com/guzzle/psr7/tree/2.10.1" }, "funding": [ { @@ -1501,7 +1502,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2026-05-20T09:27:36+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1659,16 +1660,16 @@ }, { "name": "intervention/image", - "version": "3.11.7", + "version": "3.11.8", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb" + "reference": "cf04c8dd245697f701057c13d4bfe140d584e738" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/2159bcccff18f09d2a392679b81a82c5a003f9bb", - "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb", + "url": "https://api.github.com/repos/Intervention/image/zipball/cf04c8dd245697f701057c13d4bfe140d584e738", + "reference": "cf04c8dd245697f701057c13d4bfe140d584e738", "shasum": "" }, "require": { @@ -1681,7 +1682,7 @@ "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", "slevomat/coding-standard": "~8.0", - "squizlabs/php_codesniffer": "^3.8" + "squizlabs/php_codesniffer": "^4" }, "suggest": { "ext-exif": "Recommended to be able to read EXIF data properly." @@ -1715,7 +1716,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.7" + "source": "https://github.com/Intervention/image/tree/3.11.8" }, "funding": [ { @@ -1731,20 +1732,20 @@ "type": "ko_fi" } ], - "time": "2026-02-19T13:11:17+00:00" + "time": "2026-05-01T08:20:10+00:00" }, { "name": "knplabs/knp-snappy", - "version": "v1.6.0", + "version": "v1.7.2", "source": { "type": "git", "url": "https://github.com/KnpLabs/snappy.git", - "reference": "af73003db677563fa982b50c1aec4d1e2b2f30b2" + "reference": "1461239a8b265fcc5457b7bdeb842c75b0f066eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/af73003db677563fa982b50c1aec4d1e2b2f30b2", - "reference": "af73003db677563fa982b50c1aec4d1e2b2f30b2", + "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/1461239a8b265fcc5457b7bdeb842c75b0f066eb", + "reference": "1461239a8b265fcc5457b7bdeb842c75b0f066eb", "shasum": "" }, "require": { @@ -1755,8 +1756,8 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", "pedrotroller/php-cs-custom-fixer": "^2.19", - "phpstan/phpstan": "^1.0.0", - "phpstan/phpstan-phpunit": "^1.0.0", + "phpstan/phpstan": "^2.1.39", + "phpstan/phpstan-phpunit": "^2.0.15", "phpunit/phpunit": "^9.6.29" }, "type": "library", @@ -1796,22 +1797,22 @@ ], "support": { "issues": "https://github.com/KnpLabs/snappy/issues", - "source": "https://github.com/KnpLabs/snappy/tree/v1.6.0" + "source": "https://github.com/KnpLabs/snappy/tree/v1.7.2" }, - "time": "2026-02-13T12:50:40+00:00" + "time": "2026-05-15T15:04:49+00:00" }, { "name": "laravel/framework", - "version": "v12.58.0", + "version": "v12.60.2", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "6172ae1f44ba5d89e111057ee4a4e7c27f5a610d" + "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6172ae1f44ba5d89e111057ee4a4e7c27f5a610d", - "reference": "6172ae1f44ba5d89e111057ee4a4e7c27f5a610d", + "url": "https://api.github.com/repos/laravel/framework/zipball/b8b55ce32175cc00f834a56eeb6316f18ed6ea39", + "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39", "shasum": "" }, "require": { @@ -2020,20 +2021,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-04-26T16:42:04+00:00" + "time": "2026-05-20T11:48:19+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.17", + "version": "v0.3.18", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818" + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/6a82ac19a28b916ae0885828795dbd4c59d9a818", - "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", "shasum": "" }, "require": { @@ -2077,9 +2078,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.17" + "source": "https://github.com/laravel/prompts/tree/v0.3.18" }, - "time": "2026-04-20T16:07:33+00:00" + "time": "2026-05-19T00:47:18+00:00" }, { "name": "laravel/serializable-closure", @@ -2471,16 +2472,16 @@ }, { "name": "league/flysystem", - "version": "3.33.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "570b8871e0ce693764434b29154c54b434905350" + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", - "reference": "570b8871e0ce693764434b29154c54b434905350", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", "shasum": "" }, "require": { @@ -2548,26 +2549,26 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" }, - "time": "2026-03-25T07:59:30+00:00" + "time": "2026-05-14T10:28:08+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.32.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", - "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/0c62fdac907791d8649ad3c61cb7a77628344fb8", + "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8", "shasum": "" }, "require": { - "aws/aws-sdk-php": "^3.295.10", + "aws/aws-sdk-php": "^3.371.5", "league/flysystem": "^3.10.0", "league/mime-type-detection": "^1.0.0", "php": "^8.0.2" @@ -2603,9 +2604,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.34.0" }, - "time": "2026-02-25T16:46:44+00:00" + "time": "2026-05-04T08:24:00+00:00" }, { "name": "league/flysystem-local", @@ -3534,16 +3535,16 @@ }, { "name": "nette/utils", - "version": "v4.1.3", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -3619,9 +3620,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.3" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-13T03:05:33+00:00" + "time": "2026-05-11T20:49:54+00:00" }, { "name": "nikic/php-parser", @@ -3770,21 +3771,21 @@ }, { "name": "onelogin/php-saml", - "version": "4.3.1", + "version": "4.3.2", "source": { "type": "git", "url": "https://github.com/SAML-Toolkits/php-saml.git", - "reference": "b009f160e4ac11f49366a45e0d45706b48429353" + "reference": "26b3a47349415e5b7aa300ba4ab7fc316c65f19e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/b009f160e4ac11f49366a45e0d45706b48429353", - "reference": "b009f160e4ac11f49366a45e0d45706b48429353", + "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/26b3a47349415e5b7aa300ba4ab7fc316c65f19e", + "reference": "26b3a47349415e5b7aa300ba4ab7fc316c65f19e", "shasum": "" }, "require": { "php": ">=7.3", - "robrichards/xmlseclibs": ">=3.1.4" + "robrichards/xmlseclibs": "^3.1.5" }, "require-dev": { "pdepend/pdepend": "^2.8.0", @@ -3830,7 +3831,7 @@ "type": "github" } ], - "time": "2025-12-09T10:50:49+00:00" + "time": "2026-05-07T22:38:04+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -5510,16 +5511,16 @@ }, { "name": "symfony/console", - "version": "v7.4.8", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", - "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", "shasum": "" }, "require": { @@ -5584,7 +5585,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.8" + "source": "https://github.com/symfony/console/tree/v7.4.11" }, "funding": [ { @@ -5604,20 +5605,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:54:39+00:00" + "time": "2026-05-13T12:04:42+00:00" }, { "name": "symfony/css-selector", - "version": "v7.4.8", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "b055f228a4178a1d6774909903905e3475f3eac8" + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/b055f228a4178a1d6774909903905e3475f3eac8", - "reference": "b055f228a4178a1d6774909903905e3475f3eac8", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", "shasum": "" }, "require": { @@ -5653,7 +5654,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.8" + "source": "https://github.com/symfony/css-selector/tree/v7.4.9" }, "funding": [ { @@ -5673,20 +5674,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-04-18T13:18:21+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -5699,7 +5700,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5724,7 +5725,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -5735,12 +5736,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/error-handler", @@ -5826,16 +5831,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v7.4.8", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f57b899fa736fd71121168ef268f23c206083f0a" + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f57b899fa736fd71121168ef268f23c206083f0a", - "reference": "f57b899fa736fd71121168ef268f23c206083f0a", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", "shasum": "" }, "require": { @@ -5887,7 +5892,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.8" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9" }, "funding": [ { @@ -5907,20 +5912,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:54:39+00:00" + "time": "2026-04-18T13:18:21+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", "shasum": "" }, "require": { @@ -5934,7 +5939,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5967,7 +5972,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" }, "funding": [ { @@ -5978,25 +5983,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/filesystem", - "version": "v7.4.8", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5" + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5", - "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", "shasum": "" }, "require": { @@ -6033,7 +6042,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.8" + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" }, "funding": [ { @@ -6053,7 +6062,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-11T16:38:44+00:00" }, { "name": "symfony/finder", @@ -6207,16 +6216,16 @@ }, { "name": "symfony/http-kernel", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "017e76ad089bac281553389269e259e155935e1a" + "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/017e76ad089bac281553389269e259e155935e1a", - "reference": "017e76ad089bac281553389269e259e155935e1a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", + "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", "shasum": "" }, "require": { @@ -6302,7 +6311,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.8" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.12" }, "funding": [ { @@ -6322,20 +6331,20 @@ "type": "tidelift" } ], - "time": "2026-03-31T20:57:01+00:00" + "time": "2026-05-20T09:27:11+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62" + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/f6ea532250b476bfc1b56699b388a1bdbf168f62", - "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", "shasum": "" }, "require": { @@ -6386,7 +6395,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.8" + "source": "https://github.com/symfony/mailer/tree/v7.4.12" }, "funding": [ { @@ -6406,20 +6415,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/mime", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379" + "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/6df02f99998081032da3407a8d6c4e1dcb5d4379", - "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379", + "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470", + "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470", "shasum": "" }, "require": { @@ -6475,7 +6484,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.8" + "source": "https://github.com/symfony/mime/tree/v7.4.12" }, "funding": [ { @@ -6495,7 +6504,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T14:11:46+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7328,16 +7337,16 @@ }, { "name": "symfony/process", - "version": "v7.4.8", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" + "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", - "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", + "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0", + "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0", "shasum": "" }, "require": { @@ -7369,7 +7378,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.8" + "source": "https://github.com/symfony/process/tree/v7.4.11" }, "funding": [ { @@ -7389,20 +7398,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-11T16:55:21+00:00" }, { "name": "symfony/routing", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b" + "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", - "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", + "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", + "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", "shasum": "" }, "require": { @@ -7454,7 +7463,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.8" + "source": "https://github.com/symfony/routing/tree/v7.4.12" }, "funding": [ { @@ -7474,20 +7483,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -7505,7 +7514,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -7541,7 +7550,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -7561,20 +7570,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/string", - "version": "v7.4.8", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "114ac57257d75df748eda23dd003878080b8e688" + "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688", - "reference": "114ac57257d75df748eda23dd003878080b8e688", + "url": "https://api.github.com/repos/symfony/string/zipball/965f7306a43383d02c6aca1e3f3bd2f0ea5dee15", + "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15", "shasum": "" }, "require": { @@ -7632,7 +7641,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.8" + "source": "https://github.com/symfony/string/tree/v7.4.11" }, "funding": [ { @@ -7652,20 +7661,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-13T12:04:42+00:00" }, { "name": "symfony/translation", - "version": "v7.4.8", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "33600f8489485425bfcddd0d983391038d3422e7" + "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/33600f8489485425bfcddd0d983391038d3422e7", - "reference": "33600f8489485425bfcddd0d983391038d3422e7", + "url": "https://api.github.com/repos/symfony/translation/zipball/ada7578c30dd5feaa8259cff3e885069ea81ddde", + "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde", "shasum": "" }, "require": { @@ -7732,7 +7741,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.8" + "source": "https://github.com/symfony/translation/tree/v7.4.10" }, "funding": [ { @@ -7752,20 +7761,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-06T11:19:24+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", "shasum": "" }, "require": { @@ -7778,7 +7787,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -7814,7 +7823,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" }, "funding": [ { @@ -7834,20 +7843,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/uid", - "version": "v7.4.8", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56" + "reference": "2676b524340abcfe4d6151ec698463cebafee439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/6883ebdf7bf6a12b37519dbc0df62b0222401b56", - "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", "shasum": "" }, "require": { @@ -7892,7 +7901,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.8" + "source": "https://github.com/symfony/uid/tree/v7.4.9" }, "funding": [ { @@ -7912,7 +7921,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-04-30T15:19:22+00:00" }, { "name": "symfony/var-dumper", @@ -9170,11 +9179,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.54", + "version": "2.1.55", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", - "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", + "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", "shasum": "" }, "require": { @@ -9219,7 +9228,7 @@ "type": "github" } ], - "time": "2026-04-29T13:31:09+00:00" + "time": "2026-05-18T11:57:34+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10842,16 +10851,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8" + "reference": "b59b59122690976550fd142c23fab62c84738db6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2918e7c2ba964defca1f5b69c6f74886529e2dc8", - "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b59b59122690976550fd142c23fab62c84738db6", + "reference": "b59b59122690976550fd142c23fab62c84738db6", "shasum": "" }, "require": { @@ -10890,7 +10899,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.8" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.12" }, "funding": [ { @@ -10910,7 +10919,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "theseer/tokenizer", From ef821192267f075996efc9b98eabfb2fbb3ff4ad Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 12:27:38 +0100 Subject: [PATCH 252/398] MFA: Added verify attempt rate limiting --- .../Controllers/HandlesPartialLogins.php | 6 ++ .../Controllers/MfaBackupCodesController.php | 13 ++++ app/Access/Controllers/MfaTotpController.php | 11 +++- app/Access/LoginService.php | 2 +- app/Access/Mfa/MfaVerificationLimiter.php | 62 +++++++++++++++++++ lang/en/auth.php | 1 + tests/Auth/MfaVerificationTest.php | 42 +++++++++++++ 7 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 app/Access/Mfa/MfaVerificationLimiter.php diff --git a/app/Access/Controllers/HandlesPartialLogins.php b/app/Access/Controllers/HandlesPartialLogins.php index 47a63d19b0c..8afad2776d1 100644 --- a/app/Access/Controllers/HandlesPartialLogins.php +++ b/app/Access/Controllers/HandlesPartialLogins.php @@ -22,4 +22,10 @@ protected function currentOrLastAttemptedUser(): User return $user; } + + protected function clearLastAttemptedUser(): void + { + $loginService = app()->make(LoginService::class); + $loginService->clearLastLoginAttempted(); + } } diff --git a/app/Access/Controllers/MfaBackupCodesController.php b/app/Access/Controllers/MfaBackupCodesController.php index 5c334674e7d..6ab3e1d1466 100644 --- a/app/Access/Controllers/MfaBackupCodesController.php +++ b/app/Access/Controllers/MfaBackupCodesController.php @@ -6,6 +6,7 @@ use BookStack\Access\Mfa\BackupCodeService; use BookStack\Access\Mfa\MfaSession; use BookStack\Access\Mfa\MfaValue; +use BookStack\Access\Mfa\MfaVerificationLimiter; use BookStack\Activity\ActivityType; use BookStack\Exceptions\NotFoundException; use BookStack\Http\Controller; @@ -19,6 +20,11 @@ class MfaBackupCodesController extends Controller protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-backup-codes'; + public function __construct( + protected MfaVerificationLimiter $limiter, + ) { + } + /** * Show a view that generates and displays backup codes. */ @@ -71,6 +77,12 @@ public function confirm() public function verify(Request $request, BackupCodeService $codeService, MfaSession $mfaSession, LoginService $loginService) { $user = $this->currentOrLastAttemptedUser(); + $this->limiter->incrementAttempts($user, $request); + if ($this->limiter->hasHitLimit($user, $request)) { + $this->clearLastAttemptedUser(); + $this->limiter->throwException(); + } + $codes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES) ?? '[]'; $this->validate($request, [ @@ -89,6 +101,7 @@ function ($attribute, $value, $fail) use ($codeService, $codes) { $mfaSession->markVerifiedForUser($user); $loginService->reattemptLoginFor($user); + $this->limiter->decrementAttempts($user, $request); if ($codeService->countCodesInSet($updatedCodes) < 5) { $this->showWarningNotification(trans('auth.mfa_backup_codes_usage_limit_warning')); diff --git a/app/Access/Controllers/MfaTotpController.php b/app/Access/Controllers/MfaTotpController.php index 5202fedc04f..b8a33322857 100644 --- a/app/Access/Controllers/MfaTotpController.php +++ b/app/Access/Controllers/MfaTotpController.php @@ -5,6 +5,7 @@ use BookStack\Access\LoginService; use BookStack\Access\Mfa\MfaSession; use BookStack\Access\Mfa\MfaValue; +use BookStack\Access\Mfa\MfaVerificationLimiter; use BookStack\Access\Mfa\TotpService; use BookStack\Access\Mfa\TotpValidationRule; use BookStack\Activity\ActivityType; @@ -20,7 +21,8 @@ class MfaTotpController extends Controller protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret'; public function __construct( - protected TotpService $totp + protected TotpService $totp, + protected MfaVerificationLimiter $limiter, ) { } @@ -86,6 +88,12 @@ public function confirm(Request $request) public function verify(Request $request, LoginService $loginService, MfaSession $mfaSession) { $user = $this->currentOrLastAttemptedUser(); + $this->limiter->incrementAttempts($user, $request); + if ($this->limiter->hasHitLimit($user, $request)) { + $this->clearLastAttemptedUser(); + $this->limiter->throwException(); + } + $totpSecret = MfaValue::getValueForUser($user, MfaValue::METHOD_TOTP); $this->validate($request, [ @@ -98,6 +106,7 @@ public function verify(Request $request, LoginService $loginService, MfaSession $mfaSession->markVerifiedForUser($user); $loginService->reattemptLoginFor($user); + $this->limiter->decrementAttempts($user, $request); return redirect()->intended(); } diff --git a/app/Access/LoginService.php b/app/Access/LoginService.php index c81e955722c..f089f5ba92c 100644 --- a/app/Access/LoginService.php +++ b/app/Access/LoginService.php @@ -126,7 +126,7 @@ protected function setLastLoginAttemptedForUser(User $user, string $method, bool /** * Clear the last login attempted session value. */ - protected function clearLastLoginAttempted(): void + public function clearLastLoginAttempted(): void { session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY); } diff --git a/app/Access/Mfa/MfaVerificationLimiter.php b/app/Access/Mfa/MfaVerificationLimiter.php new file mode 100644 index 00000000000..3ff0adad43d --- /dev/null +++ b/app/Access/Mfa/MfaVerificationLimiter.php @@ -0,0 +1,62 @@ + 60]), + '/login', + Response::HTTP_TOO_MANY_REQUESTS + ); + } + + public function incrementAttempts(User $user, Request $request): void + { + $this->rateLimiter->hit($this->getUserKey($user)); + $this->rateLimiter->hit($this->getRequestKey($request)); + } + + public function decrementAttempts(User $user, Request $request): void + { + $this->rateLimiter->decrement($this->getUserKey($user)); + $this->rateLimiter->decrement($this->getRequestKey($request)); + } + + public function hasHitLimit(User $user, Request $request): bool + { + return $this->rateLimiter->tooManyAttempts($this->getUserKey($user), $this->maxUserAttemptsPerMinute + 1) + || $this->rateLimiter->tooManyAttempts($this->getRequestKey($request), $this->maxIpAttemptsPerMinute + 1); + } + + protected function getUserKey(User $user): string + { + return "mfa-attempt::user::{$user->id}"; + } + + protected function getRequestKey(Request $request): string + { + return "mfa-attempt::request::{$request->ip()}"; + } +} diff --git a/lang/en/auth.php b/lang/en/auth.php index 57f0cb5c632..47be4ea721e 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -8,6 +8,7 @@ 'failed' => 'These credentials do not match our records.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Sign up', diff --git a/tests/Auth/MfaVerificationTest.php b/tests/Auth/MfaVerificationTest.php index 76c59bc748b..967be1c6a61 100644 --- a/tests/Auth/MfaVerificationTest.php +++ b/tests/Auth/MfaVerificationTest.php @@ -66,6 +66,27 @@ public function test_totp_form_has_autofill_configured() $html->assertElementExists('input[autocomplete="one-time-code"][name="code"]'); } + public function test_totp_verification_is_rate_limited() + { + [$user, $secret, $loginResp] = $this->startTotpLogin(); + $loginService = $this->app->make(LoginService::class); + + $resp = $this->get('/mfa/verify'); + for ($i = 0; $i < 5; $i++) { + $this->post('/mfa/totp/verify', [ + 'code' => '123456', + ])->assertRedirect('/mfa/verify'); + $this->assertNotNull($loginService->getLastLoginAttemptUser()); + } + + $resp = $this->post('/mfa/totp/verify', [ + 'code' => '123456', + ]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Too many multi-factor verification attempts. Please try again in 60 seconds.'); + $this->assertNull($loginService->getLastLoginAttemptUser()); + } + public function test_backup_code_verification() { [$user, $codes, $loginResp] = $this->startBackupCodeLogin(); @@ -147,6 +168,27 @@ public function test_backup_code_verification_shows_warning_when_limited_codes_r $resp->assertSeeText('You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.'); } + public function test_backup_code_verification_is_rate_limited() + { + [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']); + $loginService = $this->app->make(LoginService::class); + + $resp = $this->get('/mfa/verify'); + for ($i = 0; $i < 5; $i++) { + $this->post('/mfa/backup_codes/verify', [ + 'code' => '123456abcd', + ])->assertRedirect('/mfa/verify'); + $this->assertNotNull($loginService->getLastLoginAttemptUser()); + } + + $resp = $this->post('/mfa/backup_codes/verify', [ + 'code' => '123456abcd', + ]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Too many multi-factor verification attempts. Please try again in 60 seconds.'); + $this->assertNull($loginService->getLastLoginAttemptUser()); + } + public function test_backup_code_form_has_autofill_configured() { [$user, $codes, $loginResp] = $this->startBackupCodeLogin(); From d7ba0dc43082e09bd253bc92998c47293b0b1502 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 26 May 2026 12:50:19 +0100 Subject: [PATCH 253/398] CSP: Renamed CSS CSP option --- .env.example.complete | 4 ++-- app/Config/app.php | 4 ++-- app/Util/CspService.php | 2 +- phpunit.xml | 2 +- tests/SecurityHeaderTest.php | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.env.example.complete b/.env.example.complete index ed0cac5f473..6c773f601f1 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -398,8 +398,8 @@ ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtub # A list of sources/hostnames that can be loaded as CSS styles within BookStack. # Space separated if multiple. BookStack host domain is auto-inferred. # Defaults to a permissive set if not provided. -# Example: ALLOWED_CSS_SOURCES="https://fonts.googleapis.com" -ALLOWED_CSS_SOURCES=null +# Example: ALLOWED_STYLE_SOURCES="https://fonts.googleapis.com" +ALLOWED_STYLE_SOURCES=null # A list of sources/hostnames that can be loaded as image content within BookStack. # Space separated if multiple. BookStack host domain is auto-inferred, in addition to diff --git a/app/Config/app.php b/app/Config/app.php index 12f285ea9e1..c38cd0e1f43 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -72,10 +72,10 @@ // Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured. 'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'), - // A list of sources/hostnames that can be loaded as CSS styles within BookStack. + // A list of style sources/hostnames that can be loaded styles within BookStack. // Space separated if multiple. BookStack host domain is auto-inferred. // If not set, a permissive default set is used to reduce potential breakage. - 'css_sources' => env('ALLOWED_CSS_SOURCES', null), + 'style_sources' => env('ALLOWED_STYLE_SOURCES', null), // A list of sources/hostnames that can be loaded as image content within BookStack. // Space separated if multiple. BookStack host domain is auto-inferred, in addition to diff --git a/app/Util/CspService.php b/app/Util/CspService.php index 68c544ff09d..9b871e7ff3b 100644 --- a/app/Util/CspService.php +++ b/app/Util/CspService.php @@ -169,7 +169,7 @@ protected function getAllowedIframeSources(): array */ protected function getAllowedStyleSources(): array { - $configured = config('app.css_sources'); + $configured = config('app.style_sources'); if (is_string($configured)) { $sources = array_filter(explode(' ', $configured)); diff --git a/phpunit.xml b/phpunit.xml index 52c5e7de8e9..55d84d129e6 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,7 @@ - + diff --git a/tests/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php index 8badc39b9b3..5664084ff15 100644 --- a/tests/SecurityHeaderTest.php +++ b/tests/SecurityHeaderTest.php @@ -153,7 +153,7 @@ public function test_frame_src_csp_header_drawio_host_includes_port_if_existing( public function test_style_src_csp_header_set_to_permissive_defaults_when_not_configured() { - config()->set('app.css_sources', null); + config()->set('app.style_sources', null); $resp = $this->get('/'); $header = $this->getCspHeader($resp, 'style-src'); @@ -162,7 +162,7 @@ public function test_style_src_csp_header_set_to_permissive_defaults_when_not_co public function test_style_src_csp_header_can_be_overridden_by_config() { - config()->set('app.css_sources', 'https://fonts.example.com'); + config()->set('app.style_sources', 'https://fonts.example.com'); $resp = $this->get('/'); $header = $this->getCspHeader($resp, 'style-src'); @@ -172,7 +172,7 @@ public function test_style_src_csp_header_can_be_overridden_by_config() public function test_style_src_csp_header_unsafe_inline_value_will_be_auto_quoted() { - config()->set('app.css_sources', 'unsafe-inline https://css.example.com'); + config()->set('app.style_sources', 'unsafe-inline https://css.example.com'); $resp = $this->get('/'); $header = $this->getCspHeader($resp, 'style-src'); @@ -182,7 +182,7 @@ public function test_style_src_csp_header_unsafe_inline_value_will_be_auto_quote public function test_style_src_can_be_blank_to_set_no_additions() { - config()->set('app.css_sources', ''); + config()->set('app.style_sources', ''); $resp = $this->get('/'); $header = $this->getCspHeader($resp, 'style-src'); From 99e405f80f4be5c3b6cc89afe4a1fd5c4c0cd385 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 26 May 2026 12:58:48 +0100 Subject: [PATCH 254/398] Page Editor: Added contents click handling for TinyMCE editor --- resources/js/wysiwyg-tinymce/common-events.js | 7 +++++++ resources/js/wysiwyg-tinymce/scrolling.js | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/resources/js/wysiwyg-tinymce/common-events.js b/resources/js/wysiwyg-tinymce/common-events.js index d0a5acdc24d..6f3d34f1f8e 100644 --- a/resources/js/wysiwyg-tinymce/common-events.js +++ b/resources/js/wysiwyg-tinymce/common-events.js @@ -1,3 +1,5 @@ +import {scrollToHeader} from "./scrolling"; + /** * @param {Editor} editor */ @@ -30,4 +32,9 @@ export function listen(editor) { editor.focus(); } }); + + // Focus on a specific heading + window.$events.listen('editor::focus-heading', ({index}) => { + scrollToHeader(editor, index); + }); } diff --git a/resources/js/wysiwyg-tinymce/scrolling.js b/resources/js/wysiwyg-tinymce/scrolling.js index 92f8f158323..b4f07984ea6 100644 --- a/resources/js/wysiwyg-tinymce/scrolling.js +++ b/resources/js/wysiwyg-tinymce/scrolling.js @@ -15,6 +15,23 @@ function scrollToText(editor, scrollId) { editor.focus(); } +/** + * Scroll to a specific header of the given index, relative to all headers in the content. + * @param {Editor} editor + * @param {Number} index + */ +export function scrollToHeader(editor, index) { + const headers = editor.dom.select('h1, h2, h3, h4, h5, h6'); + const targetHeader = headers[index]; + + if (targetHeader) { + targetHeader.scrollIntoView(); + editor.selection.select(targetHeader, true); + editor.selection.collapse(false); + editor.focus(); + } +} + /** * Scroll to a section dictated by the current URL query string, if present. * Used when directly editing a specific section of the page. From 0de1196b62efd626066dd893099edab35604a158 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 26 May 2026 13:27:48 +0100 Subject: [PATCH 255/398] Page Editor: Minor fixes - Removed unused import - Added some trailing newlines to code files - Prevented
    s confusing logic in MD editor - Aligned logic to select end of header across editors --- resources/js/components/toolbox-contents.ts | 2 +- resources/js/markdown/actions.ts | 2 +- resources/js/markdown/common-events.ts | 1 - resources/js/wysiwyg/utils/actions.ts | 4 ++-- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/resources/js/components/toolbox-contents.ts b/resources/js/components/toolbox-contents.ts index 569b574ee37..1cbaeda5dda 100644 --- a/resources/js/components/toolbox-contents.ts +++ b/resources/js/components/toolbox-contents.ts @@ -149,4 +149,4 @@ export class ToolboxContents extends Component { class: 'sidebar-page-nav menu' }, headerItems); } -} \ No newline at end of file +} diff --git a/resources/js/markdown/actions.ts b/resources/js/markdown/actions.ts index ff1f0bc9435..2fd02c8c8d3 100644 --- a/resources/js/markdown/actions.ts +++ b/resources/js/markdown/actions.ts @@ -195,7 +195,7 @@ export class Actions { * Focus on the text for a specific header in the content. */ focusOnHeader(index: number): void { - const headerPattern = /^\s{0,3}(#+|])/i; const codeBoundary = /^\s{0,3}```/i; let currentIndex = -1; let inCodeBoundary = false; diff --git a/resources/js/markdown/common-events.ts b/resources/js/markdown/common-events.ts index e8cbe561c8e..e24f39dab8a 100644 --- a/resources/js/markdown/common-events.ts +++ b/resources/js/markdown/common-events.ts @@ -1,5 +1,4 @@ import {MarkdownEditor} from "./index.mjs"; -import {focusOnHeader} from "../wysiwyg/utils/actions"; export interface HtmlOrMarkdown { html: string; diff --git a/resources/js/wysiwyg/utils/actions.ts b/resources/js/wysiwyg/utils/actions.ts index f5b2db6aad4..c1d819f2226 100644 --- a/resources/js/wysiwyg/utils/actions.ts +++ b/resources/js/wysiwyg/utils/actions.ts @@ -109,7 +109,7 @@ export function focusOnHeader(editor: LexicalEditor, headerIndex: number): void const headers = $getAllNodesOfType($isHeadingNode); const target = headers[headerIndex]; if (target) { - target.selectStart(); + target.selectEnd(); } }); -} \ No newline at end of file +} From c26b66887fc70e878c3aad74a9ab8db6217a9d89 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 28 May 2026 11:29:39 +0200 Subject: [PATCH 256/398] Updated translations with changes from Crowdin (#6139) ## 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. Co-authored-by: Crowdin Bot Reviewed-on: https://codeberg.org/bookstack/bookstack/pulls/6139 --- lang/ar/activities.php | 2 + lang/ar/auth.php | 1 + lang/ar/entities.php | 3 + lang/ar/settings.php | 4 + lang/bg/activities.php | 2 + lang/bg/auth.php | 1 + lang/bg/entities.php | 3 + lang/bg/settings.php | 4 + lang/bn/activities.php | 2 + lang/bn/auth.php | 1 + lang/bn/entities.php | 3 + lang/bn/settings.php | 4 + lang/bs/activities.php | 2 + lang/bs/auth.php | 1 + lang/bs/entities.php | 3 + lang/bs/settings.php | 4 + lang/ca/activities.php | 2 + lang/ca/auth.php | 1 + lang/ca/entities.php | 3 + lang/ca/settings.php | 4 + lang/cs/activities.php | 2 + lang/cs/auth.php | 1 + lang/cs/entities.php | 3 + lang/cs/settings.php | 4 + lang/cy/activities.php | 2 + lang/cy/auth.php | 1 + lang/cy/entities.php | 3 + lang/cy/settings.php | 4 + lang/da/activities.php | 2 + lang/da/auth.php | 1 + lang/da/entities.php | 3 + lang/da/settings.php | 4 + lang/de/activities.php | 2 + lang/de/auth.php | 3 +- lang/de/entities.php | 3 + lang/de/settings.php | 6 +- lang/de_informal/activities.php | 2 + lang/de_informal/auth.php | 1 + lang/de_informal/entities.php | 3 + lang/de_informal/settings.php | 4 + lang/el/activities.php | 2 + lang/el/auth.php | 1 + lang/el/entities.php | 3 + lang/el/settings.php | 4 + lang/es/activities.php | 2 + lang/es/auth.php | 1 + lang/es/entities.php | 3 + lang/es/settings.php | 4 + lang/es_AR/activities.php | 2 + lang/es_AR/auth.php | 1 + lang/es_AR/entities.php | 3 + lang/es_AR/settings.php | 4 + lang/et/activities.php | 2 + lang/et/auth.php | 1 + lang/et/entities.php | 5 +- lang/et/settings.php | 4 + lang/eu/activities.php | 2 + lang/eu/auth.php | 1 + lang/eu/entities.php | 3 + lang/eu/settings.php | 4 + lang/fa/activities.php | 2 + lang/fa/auth.php | 1 + lang/fa/entities.php | 3 + lang/fa/settings.php | 4 + lang/fi/activities.php | 2 + lang/fi/auth.php | 1 + lang/fi/entities.php | 3 + lang/fi/settings.php | 4 + lang/fr/activities.php | 2 + lang/fr/auth.php | 1 + lang/fr/entities.php | 3 + lang/fr/settings.php | 4 + lang/he/activities.php | 2 + lang/he/auth.php | 1 + lang/he/entities.php | 3 + lang/he/settings.php | 4 + lang/hr/activities.php | 2 + lang/hr/auth.php | 1 + lang/hr/entities.php | 3 + lang/hr/settings.php | 4 + lang/hu/activities.php | 36 +++-- lang/hu/auth.php | 63 ++++---- lang/hu/common.php | 12 +- lang/hu/components.php | 10 +- lang/hu/editor.php | 52 +++---- lang/hu/entities.php | 267 ++++++++++++++++---------------- lang/hu/errors.php | 110 ++++++------- lang/hu/notifications.php | 18 +-- lang/hu/passwords.php | 6 +- lang/hu/preferences.php | 34 ++-- lang/hu/settings.php | 232 +++++++++++++-------------- lang/hu/validation.php | 144 ++++++++--------- lang/id/activities.php | 2 + lang/id/auth.php | 1 + lang/id/entities.php | 3 + lang/id/settings.php | 4 + lang/is/activities.php | 2 + lang/is/auth.php | 1 + lang/is/entities.php | 3 + lang/is/settings.php | 4 + lang/it/activities.php | 2 + lang/it/auth.php | 1 + lang/it/entities.php | 3 + lang/it/settings.php | 6 +- lang/ja/activities.php | 2 + lang/ja/auth.php | 1 + lang/ja/entities.php | 3 + lang/ja/settings.php | 4 + lang/ka/activities.php | 2 + lang/ka/auth.php | 1 + lang/ka/entities.php | 3 + lang/ka/settings.php | 4 + lang/ko/activities.php | 2 + lang/ko/auth.php | 1 + lang/ko/entities.php | 3 + lang/ko/settings.php | 4 + lang/ku/activities.php | 2 + lang/ku/auth.php | 1 + lang/ku/entities.php | 3 + lang/ku/settings.php | 4 + lang/lt/activities.php | 2 + lang/lt/auth.php | 1 + lang/lt/entities.php | 3 + lang/lt/settings.php | 4 + lang/lv/activities.php | 2 + lang/lv/auth.php | 1 + lang/lv/entities.php | 3 + lang/lv/settings.php | 4 + lang/nb/activities.php | 2 + lang/nb/auth.php | 1 + lang/nb/entities.php | 3 + lang/nb/settings.php | 4 + lang/ne/activities.php | 2 + lang/ne/auth.php | 1 + lang/ne/entities.php | 3 + lang/ne/settings.php | 4 + lang/nl/activities.php | 2 + lang/nl/auth.php | 1 + lang/nl/entities.php | 3 + lang/nl/settings.php | 4 + lang/nn/activities.php | 2 + lang/nn/auth.php | 1 + lang/nn/entities.php | 3 + lang/nn/settings.php | 4 + lang/pl/activities.php | 2 + lang/pl/auth.php | 1 + lang/pl/entities.php | 3 + lang/pl/settings.php | 4 + lang/pt/activities.php | 2 + lang/pt/auth.php | 1 + lang/pt/entities.php | 3 + lang/pt/settings.php | 4 + lang/pt_BR/activities.php | 2 + lang/pt_BR/auth.php | 1 + lang/pt_BR/entities.php | 3 + lang/pt_BR/settings.php | 4 + lang/ro/activities.php | 2 + lang/ro/auth.php | 1 + lang/ro/entities.php | 3 + lang/ro/settings.php | 4 + lang/ru/activities.php | 2 + lang/ru/auth.php | 1 + lang/ru/entities.php | 3 + lang/ru/settings.php | 4 + lang/sk/activities.php | 2 + lang/sk/auth.php | 1 + lang/sk/entities.php | 3 + lang/sk/settings.php | 4 + lang/sl/activities.php | 2 + lang/sl/auth.php | 1 + lang/sl/entities.php | 3 + lang/sl/settings.php | 4 + lang/sq/activities.php | 2 + lang/sq/auth.php | 1 + lang/sq/entities.php | 3 + lang/sq/settings.php | 4 + lang/sr/activities.php | 2 + lang/sr/auth.php | 1 + lang/sr/entities.php | 3 + lang/sr/settings.php | 4 + lang/sv/activities.php | 2 + lang/sv/auth.php | 1 + lang/sv/entities.php | 3 + lang/sv/settings.php | 4 + lang/th/activities.php | 2 + lang/th/auth.php | 1 + lang/th/entities.php | 3 + lang/th/settings.php | 4 + lang/tk/activities.php | 2 + lang/tk/auth.php | 1 + lang/tk/entities.php | 3 + lang/tk/settings.php | 4 + lang/tr/activities.php | 2 + lang/tr/auth.php | 1 + lang/tr/entities.php | 3 + lang/tr/settings.php | 4 + lang/uk/activities.php | 2 + lang/uk/auth.php | 1 + lang/uk/entities.php | 3 + lang/uk/settings.php | 4 + lang/uz/activities.php | 2 + lang/uz/auth.php | 1 + lang/uz/entities.php | 3 + lang/uz/settings.php | 4 + lang/vi/activities.php | 2 + lang/vi/auth.php | 1 + lang/vi/entities.php | 3 + lang/vi/settings.php | 4 + lang/zh_CN/activities.php | 2 + lang/zh_CN/auth.php | 1 + lang/zh_CN/entities.php | 3 + lang/zh_CN/settings.php | 4 + lang/zh_TW/activities.php | 2 + lang/zh_TW/auth.php | 1 + lang/zh_TW/entities.php | 3 + lang/zh_TW/settings.php | 4 + 216 files changed, 1011 insertions(+), 491 deletions(-) diff --git a/lang/ar/activities.php b/lang/ar/activities.php index 69da1213846..efef9af92b1 100644 --- a/lang/ar/activities.php +++ b/lang/ar/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'تم تحديث المستخدم بنجاح', 'user_delete' => 'المستخدم المحذوف', 'user_delete_notification' => 'تم إزالة المستخدم بنجاح', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'تم إنشاء رمز واجهة برمجة التطبيقات -API-', diff --git a/lang/ar/auth.php b/lang/ar/auth.php index fb6f3d1cf72..6551df4dc3e 100644 --- a/lang/ar/auth.php +++ b/lang/ar/auth.php @@ -8,6 +8,7 @@ 'failed' => 'البيانات المعطاة لا توافق سجلاتنا.', 'throttle' => 'تجاوزت الحد الأقصى من المحاولات. الرجاء المحاولة مرة أخرى بعد :seconds ثانية/ثواني.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'إنشاء حساب', diff --git a/lang/ar/entities.php b/lang/ar/entities.php index 64192a021fd..59b0d52e065 100644 --- a/lang/ar/entities.php +++ b/lang/ar/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'تبديل الشريط الجانبي', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'وسوم الصفحة', 'chapter_tags' => 'وسوم الفصل', 'book_tags' => 'وسوم الكتاب', diff --git a/lang/ar/settings.php b/lang/ar/settings.php index a1fef8364d8..aa361e04b9a 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'إعداد المصادقة متعددة العوامل كطبقة إضافية من الأمان لحساب المستخدم الخاص بك.', 'users_mfa_x_methods' => ':count طريقة مُهيأة | :count طرق مُهيأة', 'users_mfa_configure' => 'إعداد الطرق', + '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' => 'قم بإنشاء رمز API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/bg/activities.php b/lang/bg/activities.php index f5ed71a4cc6..344423e0d6c 100644 --- a/lang/bg/activities.php +++ b/lang/bg/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Потребителят е обновен успешно', 'user_delete' => 'deleted user', 'user_delete_notification' => 'Потребителят е премахнат успешно', + '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/bg/auth.php b/lang/bg/auth.php index 4a7e56f22e0..91c7495a108 100644 --- a/lang/bg/auth.php +++ b/lang/bg/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Въведените данни не съвпадат с информацията в системата.', 'throttle' => 'Твърде много опити за влизане. Опитайте пак след :seconds секунди.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Регистриране', diff --git a/lang/bg/entities.php b/lang/bg/entities.php index 19e4f518860..c501483a3c9 100644 --- a/lang/bg/entities.php +++ b/lang/bg/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Тагове на страницата', 'chapter_tags' => 'Тагове на главата', 'book_tags' => 'Тагове на книгата', diff --git a/lang/bg/settings.php b/lang/bg/settings.php index 0554100fdd3..0af97414045 100644 --- a/lang/bg/settings.php +++ b/lang/bg/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Настрой многофакторно удостверяване като втори слой сигурност на твоя профил.', 'users_mfa_x_methods' => ':count метод е настроен|:count методи са настроени', 'users_mfa_configure' => 'Конфигурирай методи', + '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' => 'Създай API маркер', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/bn/activities.php b/lang/bn/activities.php index c9268f8abe4..a9e04399d37 100644 --- a/lang/bn/activities.php +++ b/lang/bn/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'ব্যবহারকারীটি সার্থকভাবে হালনাগাদ করা হয়েছে', 'user_delete' => 'ব্যবহারকারীটি মুছে ফেলেছেন', 'user_delete_notification' => 'ব্যবহারকারীটি সার্থকভাবে মুছে ফেলা হয়েছে', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'এপিআই টোকেনটি তৈরী করেছেন', diff --git a/lang/bn/auth.php b/lang/bn/auth.php index 30879fcb6d3..9e3c07b2cc8 100644 --- a/lang/bn/auth.php +++ b/lang/bn/auth.php @@ -8,6 +8,7 @@ 'failed' => 'প্রদত্ত তথ্যনিরূপিত কোন রেকর্ড পাওয়া যায়নি।', 'throttle' => 'লগইন প্রচেষ্টার সীমা অতিক্রান্ত। দয়া করে :seconds সেকেন্ড পর আবার চেষ্টা করুন।', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'নিবন্ধিত হোন', diff --git a/lang/bn/entities.php b/lang/bn/entities.php index 5501d2bc229..58c00ec4b27 100644 --- a/lang/bn/entities.php +++ b/lang/bn/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Page Tags', 'chapter_tags' => 'Chapter Tags', 'book_tags' => 'Book Tags', diff --git a/lang/bn/settings.php b/lang/bn/settings.php index 1bc5d1551ae..ab7fe951271 100644 --- a/lang/bn/settings.php +++ b/lang/bn/settings.php @@ -264,6 +264,9 @@ '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 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', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/bs/activities.php b/lang/bs/activities.php index e7308770a29..bf07d4a5615 100644 --- a/lang/bs/activities.php +++ b/lang/bs/activities.php @@ -99,6 +99,8 @@ '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/bs/auth.php b/lang/bs/auth.php index a8d0da78eb9..73848fbe178 100644 --- a/lang/bs/auth.php +++ b/lang/bs/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Ovi pristupni podaci se ne slažu sa našom evidencijom.', 'throttle' => 'Preveliki broj pokušaja prijave. Molimo vas da pokušate ponovo za :seconds sekundi.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registruj se', diff --git a/lang/bs/entities.php b/lang/bs/entities.php index b490c8c9405..879e2b3ea57 100644 --- a/lang/bs/entities.php +++ b/lang/bs/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Oznake stranice', 'chapter_tags' => 'Oznake poglavlja', 'book_tags' => 'Oznake knjige', diff --git a/lang/bs/settings.php b/lang/bs/settings.php index 3937c650f86..d03024a89d6 100644 --- a/lang/bs/settings.php +++ b/lang/bs/settings.php @@ -264,6 +264,9 @@ '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 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', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/ca/activities.php b/lang/ca/activities.php index 4894c279aad..d797286cb8e 100644 --- a/lang/ca/activities.php +++ b/lang/ca/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'S’ha actualitzat l’usuari', 'user_delete' => 'ha suprimit l’usuari', 'user_delete_notification' => 'S’ha suprimit l’usuari', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'ha creat el testimoni API', diff --git a/lang/ca/auth.php b/lang/ca/auth.php index ba6ede42f0c..c503c6b6bbf 100644 --- a/lang/ca/auth.php +++ b/lang/ca/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Aquestes credencials no existeixen al nostre registre.', 'throttle' => 'Massa intents d’inici de sessió. Torneu-ho a provar d’aquí :seconds segons.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registreu-vos', diff --git a/lang/ca/entities.php b/lang/ca/entities.php index edbfdbc4429..3a37d30376b 100644 --- a/lang/ca/entities.php +++ b/lang/ca/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Commuta la barra lateral', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Etiquetes de la pàgina', 'chapter_tags' => 'Etiquetes del capítol', 'book_tags' => 'Etiquetes del llibre', diff --git a/lang/ca/settings.php b/lang/ca/settings.php index 2a2106eb35e..ac60ce8e2cb 100644 --- a/lang/ca/settings.php +++ b/lang/ca/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Configureu l’autenticació multifactorial per a afegir una capa de seguretat extra al vostre compte d’usuari.', 'users_mfa_x_methods' => 'Hi ha :count mètode configurat|Hi ha :count mètodes configurats', 'users_mfa_configure' => 'Configura un mètode', + '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' => 'Crea un testimoni API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/cs/activities.php b/lang/cs/activities.php index f7e9337d3fa..3e497ecedbe 100644 --- a/lang/cs/activities.php +++ b/lang/cs/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Uživatel byl úspěšně aktualizován', 'user_delete' => 'odstranil uživatele', 'user_delete_notification' => 'Uživatel byl úspěšně odstraněn', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'API token byl vytvořen', diff --git a/lang/cs/auth.php b/lang/cs/auth.php index ad225a2fee2..4ad62ff1ae6 100644 --- a/lang/cs/auth.php +++ b/lang/cs/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Neplatné přihlašovací údaje.', 'throttle' => 'Příliš mnoho pokusů o přihlášení. Zkuste to prosím znovu za :seconds sekund.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registrace', diff --git a/lang/cs/entities.php b/lang/cs/entities.php index 7db62f7bb49..e44dcf84e62 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Skrýt/Zobrazit postranní panel', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Štítky stránky', 'chapter_tags' => 'Štítky kapitoly', 'book_tags' => 'Štítky knihy', diff --git a/lang/cs/settings.php b/lang/cs/settings.php index a7ab9927d8a..c11b7ee586e 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Nastavit vícefaktorové ověřování jako další vrstvu zabezpečení vašeho uživatelského účtu.', 'users_mfa_x_methods' => ':count nastavená metoda|:count nastavených metod', 'users_mfa_configure' => 'Konfigurovat metody', + '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' => 'Vytvořit API Token', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/cy/activities.php b/lang/cy/activities.php index 2459f2357f5..da2ac372cf1 100644 --- a/lang/cy/activities.php +++ b/lang/cy/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Diweddarwyd y defnyddiwr yn llwyddiannus', 'user_delete' => 'dileodd ddefnyddiwr', 'user_delete_notification' => 'Tynnwyd y defnyddiwr yn llwyddiannus', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'creodd docyn API', diff --git a/lang/cy/auth.php b/lang/cy/auth.php index 5c4034fcdd4..44781a9bbe9 100644 --- a/lang/cy/auth.php +++ b/lang/cy/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Nid yw\'r manylion hyn yn cyfateb i\'n cofnodion.', 'throttle' => 'Gormod o ymdrechion mewngofnodi. Rhowch gynnig arall arni o gwmpas :seconds eiliadau.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Cofrestru', diff --git a/lang/cy/entities.php b/lang/cy/entities.php index af1f6c43c92..0a042e7152a 100644 --- a/lang/cy/entities.php +++ b/lang/cy/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toglo Bar ochr', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Tagiau Tudalennau', 'chapter_tags' => 'Tagiau Penodau', 'book_tags' => 'Tagiau Llyfrau', diff --git a/lang/cy/settings.php b/lang/cy/settings.php index a0519cccf0f..816a4b89ffb 100644 --- a/lang/cy/settings.php +++ b/lang/cy/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Gosod dilysu aml-ffactor fel haen ychwanegol o ddiogelwch ar gyfer eich cyfrif defnyddiwr.', 'users_mfa_x_methods' => ':count dull wedi\'i ffurfweddu|:count dull wedi\'u ffurfweddu', 'users_mfa_configure' => 'Ffurfweddu Dulliau', + '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' => 'Creu Tocyn API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/da/activities.php b/lang/da/activities.php index fa11e8e3afb..00cb76b5f78 100644 --- a/lang/da/activities.php +++ b/lang/da/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Brugeren blev opdateret', 'user_delete' => 'slettet bruger', 'user_delete_notification' => 'Brugeren blev fjernet', + 'user_mfa_reset' => 'nulstil MFA for brugeren', + 'user_mfa_reset_notification' => 'Nulstilling af metoder til multifaktor-godkendelse', // API Tokens 'api_token_create' => 'oprettet API token', diff --git a/lang/da/auth.php b/lang/da/auth.php index dcc531125d5..95ebd6230e6 100644 --- a/lang/da/auth.php +++ b/lang/da/auth.php @@ -8,6 +8,7 @@ 'failed' => 'De indtastede brugeroplysninger stemmer ikke overens med vores registreringer.', 'throttle' => 'For mange mislykkede loginforsøg. Prøv igen om :seconds sekunder.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registrer', diff --git a/lang/da/entities.php b/lang/da/entities.php index 0aa35cc5db4..f1c5735eddf 100644 --- a/lang/da/entities.php +++ b/lang/da/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Sidebjælke til/fra', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Sidetags', 'chapter_tags' => 'Kapiteltags', 'book_tags' => 'Bogtags', diff --git a/lang/da/settings.php b/lang/da/settings.php index fb5a1c958ab..448b00c4c07 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Opsæt multi-faktor godkendelse som et ekstra lag af sikkerhed for din brugerkonto.', 'users_mfa_x_methods' => ':count metode konfigureret|:count metoder konfigureret', 'users_mfa_configure' => 'Konfigurer metoder', + 'users_mfa_reset' => 'Nulstil metoder til multifaktor-godkendelse', + 'users_mfa_reset_desc' => 'Dette vil nulstille og slette alle konfigurerede metoder til multifaktor-godkendelse for denne bruger. Hvis multifaktor-godkendelse er påkrævet for en af brugerens roller, vil vedkommende blive bedt om at konfigurere nye metoder ved næste login.', + 'users_mfa_reset_confirm' => 'Er du sikker på, at du vil nulstille multifaktorautentificering for denne bruger?', // API Tokens 'user_api_token_create' => 'Opret API-token', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/de/activities.php b/lang/de/activities.php index 025cbaeffab..a680a72c514 100644 --- a/lang/de/activities.php +++ b/lang/de/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Benutzer erfolgreich aktualisiert', 'user_delete' => 'hat Benutzer gelöscht: ', 'user_delete_notification' => 'Benutzer erfolgreich entfernt', + 'user_mfa_reset' => 'Setze MFA für Nutzer zurück', + 'user_mfa_reset_notification' => 'Multifaktor-Authenifizierungsmethoden zurücksetzen', // API Tokens 'api_token_create' => 'API-Token erstellt', diff --git a/lang/de/auth.php b/lang/de/auth.php index 92789d18216..0ae2dcd9799 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Diese Anmeldedaten stimmen nicht mit unseren Aufzeichnungen überein.', 'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in :seconds Sekunden erneut.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registrieren', @@ -39,7 +40,7 @@ 'register_success' => 'Vielen Dank für Ihre Registrierung! Die Daten sind gespeichert und Sie sind angemeldet.', // Login auto-initiation - 'auto_init_starting' => 'Anmeldeversuche', + 'auto_init_starting' => 'Anmeldeversuch', 'auto_init_starting_desc' => 'Wir verbinden uns mit Ihrem Authentifizierungssystem, um den Anmeldeprozess zu starten. Sollte es nach 5 Sekunden nicht weitergehen, klicken Sie bitte auf den unten stehenden Link.', 'auto_init_start_link' => 'Mit Authentifizierung fortfahren', diff --git a/lang/de/entities.php b/lang/de/entities.php index db5d070f444..a35a5f4c465 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Seitenleiste umschalten', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Seiten-Schlagwörter', 'chapter_tags' => 'Kapitel-Schlagwörter', 'book_tags' => 'Buch-Schlagwörter', diff --git a/lang/de/settings.php b/lang/de/settings.php index 691ee2ee68b..983de75baf6 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -147,7 +147,7 @@ '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' => 'Löscht :count Elemente aus dem Papierkorb.', + 'recycle_bin_destroy_notification' => ':count Elemente aus dem Papierkorb gelöscht.', 'recycle_bin_restore_notification' => 'Es wurden :count der Elemente aus dem Papierkorb wiederhergestellt.', // Audit Log @@ -264,6 +264,9 @@ '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', + 'users_mfa_reset' => 'Setze Multifaktor-Authentifizierung zurück', + 'users_mfa_reset_desc' => 'Dies wird alle konfigurierten Multifaktor-Authentifizierungsmethoden für diesen Nutzer zurücksetzen. Falls Multifaktor-Authentifizierung für eine seiner Rollen erforderlich ist, werden sie aufgefordert, neue Methoden beim nächsten Login zu konfigurieren.', + 'users_mfa_reset_confirm' => 'Sind Sie sicher, dass Sie diese Multi-Faktor-Authentifizierungsmethode für diesen Nutzer zurücksetzen möchten?', // API Tokens 'user_api_token_create' => 'Neuen API-Token erstellen', @@ -364,6 +367,7 @@ 'sk' => 'Slowenisch', 'sl' => 'Slowenisch', 'sv' => 'Schwedisch', + 'th' => 'ภาษาไทย', 'tr' => 'Türkisch', 'uk' => 'Ukrainisch', 'uz' => 'O‘zbekcha', diff --git a/lang/de_informal/activities.php b/lang/de_informal/activities.php index 990bfa0bc56..85a572c5cd9 100644 --- a/lang/de_informal/activities.php +++ b/lang/de_informal/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Benutzer erfolgreich aktualisiert', 'user_delete' => 'hat Benutzer gelöscht: ', 'user_delete_notification' => 'Benutzer erfolgreich entfernt', + 'user_mfa_reset' => 'Setze MFA für Nutzer zurück', + 'user_mfa_reset_notification' => 'Multifaktor-Authenifizierungsmethoden zurücksetzen', // API Tokens 'api_token_create' => 'API Token wurde erstellt', diff --git a/lang/de_informal/auth.php b/lang/de_informal/auth.php index 17dcdbcb26f..b91db255170 100644 --- a/lang/de_informal/auth.php +++ b/lang/de_informal/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Die eingegebenen Anmeldedaten sind ungültig.', 'throttle' => 'Zu viele Anmeldeversuche. Bitte versuche es in :seconds Sekunden erneut.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registrieren', diff --git a/lang/de_informal/entities.php b/lang/de_informal/entities.php index 708397e7e11..4b4c7e4ef5f 100644 --- a/lang/de_informal/entities.php +++ b/lang/de_informal/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Seitenleiste umschalten', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Seiten-Schlagwörter', 'chapter_tags' => 'Kapitel-Schlagwörter', 'book_tags' => 'Buch-Schlagwörter', diff --git a/lang/de_informal/settings.php b/lang/de_informal/settings.php index 93ed1902fb5..84e0c8b1042 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -265,6 +265,9 @@ 'users_mfa_desc' => 'Richte Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für dein Benutzerkonto ein.', 'users_mfa_x_methods' => ':count Methode konfiguriert|:count Methoden konfiguriert', 'users_mfa_configure' => 'Methoden konfigurieren', + 'users_mfa_reset' => 'Setze Multifaktor-Authentifizierung zurück', + 'users_mfa_reset_desc' => 'Dies wird alle konfigurierten Multifaktor-Authentifizierungsmethoden für diesen Nutzer zurücksetzen. Falls Multifaktor-Authentifizierung für eine seiner Rollen erforderlich ist, werden sie aufgefordert, neue Methoden beim nächsten Login zu konfigurieren.', + 'users_mfa_reset_confirm' => 'Sind Sie sicher, dass Sie diese Multi-Faktor-Authentifizierungsmethode für diesen Nutzer zurücksetzen möchten?', // API Tokens 'user_api_token_create' => 'Neuen API-Token erstellen', @@ -365,6 +368,7 @@ 'sk' => 'Slowenisch', 'sl' => 'Slowenisch', 'sv' => 'Schwedisch', + 'th' => 'ภาษาไทย', 'tr' => 'Türkisch', 'uk' => 'Ukrainisch', 'uz' => 'O‘zbekcha', diff --git a/lang/el/activities.php b/lang/el/activities.php index 226ef254074..bd82628f47f 100644 --- a/lang/el/activities.php +++ b/lang/el/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Ο Χρήστης ενημερώθηκε με επιτυχία', 'user_delete' => 'διαγραμμένος χρήστης', 'user_delete_notification' => 'Ο Χρήστης αφαιρέθηκε επιτυχώς', + '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/el/auth.php b/lang/el/auth.php index 0b94ef859dd..b51208fbdf2 100644 --- a/lang/el/auth.php +++ b/lang/el/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Αυτά τα διαπιστευτήρια δεν ταιριάζουν με τα αρχεία μας.', 'throttle' => 'Πάρα πολλές προσπάθειες σύνδεσης. Δοκιμάστε ξανά σε :δευτερόλεπτα.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Εγγραφείτε', diff --git a/lang/el/entities.php b/lang/el/entities.php index b551d1a86cb..06b2633615f 100644 --- a/lang/el/entities.php +++ b/lang/el/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Ετικέτες Σελίδας', 'chapter_tags' => 'Ετικέτες Κεφαλαίου', 'book_tags' => 'Ετικέτες Βιβλίου', diff --git a/lang/el/settings.php b/lang/el/settings.php index 605b8b40e01..42422a8e7da 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Ρυθμίστε τον έλεγχο ταυτότητας πολλαπλών παραγόντων ως ένα επιπλέον επίπεδο ασφάλειας για τον λογαριασμό χρήστη σας.', 'users_mfa_x_methods' => 'Έχει ρυθμιστεί :count μέθοδος|Έχουν ρυθμιστεί :count μέθοδοι', 'users_mfa_configure' => 'Ρύθμιση Μεθόδων', + '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' => 'Δημιουργία διακριτικού (API Token)', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/es/activities.php b/lang/es/activities.php index 89a11c63a2e..ecfffeb5194 100644 --- a/lang/es/activities.php +++ b/lang/es/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Usuario actualizado correctamente', 'user_delete' => 'usuario eliminado', 'user_delete_notification' => 'Usuario eliminado correctamente', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'token de API creado', diff --git a/lang/es/auth.php b/lang/es/auth.php index 45d3eb32916..467508001e9 100644 --- a/lang/es/auth.php +++ b/lang/es/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Estas credenciales no coinciden con nuestros registros.', 'throttle' => 'Demasiados intentos de inicio de sesión. Por favor, inténtalo de nuevo en :seconds segundos.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registrarse', diff --git a/lang/es/entities.php b/lang/es/entities.php index a8ec3b10ad2..7814161222b 100644 --- a/lang/es/entities.php +++ b/lang/es/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Mostrar/ocultar barra lateral', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Etiquetas de Página', 'chapter_tags' => 'Etiquetas de Capítulo', 'book_tags' => 'Etiquetas de Libro', diff --git a/lang/es/settings.php b/lang/es/settings.php index 516480b6440..2fe672f83db 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'La autenticación en dos pasos añade una capa de seguridad adicional a tu cuenta.', 'users_mfa_x_methods' => ':count método configurado|:count métodos configurados', 'users_mfa_configure' => 'Configurar métodos', + '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' => 'Crear token API', @@ -364,6 +367,7 @@ 'sk' => 'Eslovaco', 'sl' => 'Esloveno', 'sv' => 'Sueco', + 'th' => 'ภาษาไทย', 'tr' => 'Turco', 'uk' => 'Ucraniano', 'uz' => 'O‘zbekcha', diff --git a/lang/es_AR/activities.php b/lang/es_AR/activities.php index e01fe4e643e..b4ba5690e0e 100644 --- a/lang/es_AR/activities.php +++ b/lang/es_AR/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Usuario actualizado con éxito', 'user_delete' => 'usuario eliminado', 'user_delete_notification' => 'El usuario fue eliminado correctamente', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'token de API creado', diff --git a/lang/es_AR/auth.php b/lang/es_AR/auth.php index 7687f709e92..ad69e651ea0 100644 --- a/lang/es_AR/auth.php +++ b/lang/es_AR/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Estas credenciales no concuerdan con nuestros registros.', 'throttle' => 'Demasiados intentos fallidos de inicio de sesión. Por favor intente nuevamente en :seconds segundos.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registrarse', diff --git a/lang/es_AR/entities.php b/lang/es_AR/entities.php index 2dc1bd64801..aac46b4477a 100644 --- a/lang/es_AR/entities.php +++ b/lang/es_AR/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Mostrar/ocultar barra lateral', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Etiquetas de página', 'chapter_tags' => 'Etiquetas de capítulo', 'book_tags' => 'Etiquetas de libro', diff --git a/lang/es_AR/settings.php b/lang/es_AR/settings.php index 3b82d0fb35c..5545b91f3bc 100644 --- a/lang/es_AR/settings.php +++ b/lang/es_AR/settings.php @@ -265,6 +265,9 @@ 'users_mfa_desc' => 'Configure la autenticación de múltiples factores como una capa extra de seguridad para su cuenta de usuario.', 'users_mfa_x_methods' => ':count método configurado|:count métodos configurados', 'users_mfa_configure' => 'Configurar Métodos', + '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' => 'Crear token API', @@ -365,6 +368,7 @@ 'sk' => 'Eslovaco', 'sl' => 'Esloveno', 'sv' => 'Sueco', + 'th' => 'ภาษาไทย', 'tr' => 'Turco', 'uk' => 'Ucraniano', 'uz' => 'O‘zbekcha', diff --git a/lang/et/activities.php b/lang/et/activities.php index 3a86dcd8623..e42dd7fda36 100644 --- a/lang/et/activities.php +++ b/lang/et/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Kasutaja on muudetud', 'user_delete' => 'kustutas kasutaja', 'user_delete_notification' => 'Kasutaja on kustutatud', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'lisas API tunnuse', diff --git a/lang/et/auth.php b/lang/et/auth.php index 014a9f858eb..afbd2870bc7 100644 --- a/lang/et/auth.php +++ b/lang/et/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Kasutajanimi ja parool ei klapi.', 'throttle' => 'Liiga palju sisselogimiskatseid. Proovi uuesti :seconds sekundi pärast.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registreeru', diff --git a/lang/et/entities.php b/lang/et/entities.php index 4b7be605ac5..11c2f77aeb2 100644 --- a/lang/et/entities.php +++ b/lang/et/entities.php @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => 'Automaatse sorteerimise reegleid saab lisada vajalike õigustega kasutaja "Loendid ja järjestamine" seadetes.', 'books_sort_named' => 'Sorteeri raamat :bookName', 'books_sort_name' => 'Sorteeri nime järgi', 'books_sort_created' => 'Sorteeri loomisaja järgi', @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Kuva/peida külgriba', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Lehe sildid', 'chapter_tags' => 'Peatüki sildid', 'book_tags' => 'Raamatu sildid', diff --git a/lang/et/settings.php b/lang/et/settings.php index fd00da9e3d2..3e28eae95f5 100644 --- a/lang/et/settings.php +++ b/lang/et/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Seadista mitmeastmeline autentimine, et oma kasutajakonto turvalisust tõsta.', 'users_mfa_x_methods' => ':count meetod seadistatud|:count meetodit seadistatud', 'users_mfa_configure' => 'Seadista meetodid', + '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' => 'Lisa API tunnus', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Sloveenia', 'sv' => 'Rootsi', + 'th' => 'ภาษาไทย', 'tr' => 'Türgi', 'uk' => 'Ukraina', 'uz' => 'O‘zbekcha', diff --git a/lang/eu/activities.php b/lang/eu/activities.php index 847014efe74..dadee1315d3 100644 --- a/lang/eu/activities.php +++ b/lang/eu/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Erabiltzailea egoki eguneratua', 'user_delete' => 'deleted user', 'user_delete_notification' => 'Erabiltzailea egoki ezabatua', + '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/eu/auth.php b/lang/eu/auth.php index 35cbafd721b..6033035bd87 100644 --- a/lang/eu/auth.php +++ b/lang/eu/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Kredentzial hauek ez dira egokiak.', 'throttle' => 'Login saiakera kopurua pasa duzu. Mesedez, saiatu berriz :seconds segundu barru.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Izena eman', diff --git a/lang/eu/entities.php b/lang/eu/entities.php index 4d9e7cb064a..43277cc5500 100644 --- a/lang/eu/entities.php +++ b/lang/eu/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Orrialde etiketak', 'chapter_tags' => 'Kapitulu etiketak', 'book_tags' => 'Liburu etiketak', diff --git a/lang/eu/settings.php b/lang/eu/settings.php index dd346d1c9c9..ffa2c182e81 100644 --- a/lang/eu/settings.php +++ b/lang/eu/settings.php @@ -264,6 +264,9 @@ '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 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' => 'Sortu Tokena', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/fa/activities.php b/lang/fa/activities.php index 07eb78f3709..876c545a67f 100644 --- a/lang/fa/activities.php +++ b/lang/fa/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'کاربر با موفقیت به روز شد', 'user_delete' => 'کاربر حذف شده', 'user_delete_notification' => 'کاربر با موفقیت حذف شد', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'ایجاد توکن API', diff --git a/lang/fa/auth.php b/lang/fa/auth.php index c92f5f22b8a..1896663996c 100644 --- a/lang/fa/auth.php +++ b/lang/fa/auth.php @@ -8,6 +8,7 @@ 'failed' => 'مشخصات وارد شده با اطلاعات ما سازگار نیست.', 'throttle' => 'دفعات تلاش شما برای ورود بیش از حد مجاز است. لطفا پس از :seconds ثانیه مجددا تلاش فرمایید.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'ثبت نام', diff --git a/lang/fa/entities.php b/lang/fa/entities.php index 2abcff5768f..afeeca0e045 100644 --- a/lang/fa/entities.php +++ b/lang/fa/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'نمایش/پنهان‌سازی نوار کناری', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'برچسب‌های صفحه', 'chapter_tags' => 'برچسب‌های فصل', 'book_tags' => 'برچسب های کتاب', diff --git a/lang/fa/settings.php b/lang/fa/settings.php index 21ad9624015..bb1b1ca7e2d 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'تنظیم احراز هویت چند مرحله ای یک لایه امنیتی دیگر به حساب شما اضافه میکند.', 'users_mfa_x_methods' => ':count روش پیکربندی شده است|:count روش های پیکربندی شده', 'users_mfa_configure' => 'روش پیکربندی', + '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' => 'ایجاد توکن API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/fi/activities.php b/lang/fi/activities.php index a5ee14906ef..95c192e229f 100644 --- a/lang/fi/activities.php +++ b/lang/fi/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Käyttäjä päivitettiin onnistuneesti', 'user_delete' => 'poisti käyttäjän', 'user_delete_notification' => 'Käyttäjä poistettiin onnistuneesti', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'loi API-tunnisteen', diff --git a/lang/fi/auth.php b/lang/fi/auth.php index 580c8679726..16d6de346a4 100644 --- a/lang/fi/auth.php +++ b/lang/fi/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Annettuja käyttäjätietoja ei löydy.', 'throttle' => 'Liikaa kirjautumisyrityksiä. Yritä uudelleen :seconds sekunnin päästä.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Rekisteröidy', diff --git a/lang/fi/entities.php b/lang/fi/entities.php index dd2ad5e69d3..db53669bbae 100644 --- a/lang/fi/entities.php +++ b/lang/fi/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Näytä/piilota sivupalkki', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Sivun tunnisteet', 'chapter_tags' => 'Lukujen tunnisteet', 'book_tags' => 'Kirjojen tunnisteet', diff --git a/lang/fi/settings.php b/lang/fi/settings.php index 3aec0188670..aa8ac3e592b 100644 --- a/lang/fi/settings.php +++ b/lang/fi/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Paranna käyttäjätilisi turvallisuutta ja ota käyttöön monivaiheinen tunnistautuminen.', 'users_mfa_x_methods' => ':count menetelmä määritetty|:count menetelmää määritetty', 'users_mfa_configure' => 'Määritä menetelmiä', + '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' => 'Luo uusi API-tunniste', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/fr/activities.php b/lang/fr/activities.php index 0e70917da55..fcacc90898e 100644 --- a/lang/fr/activities.php +++ b/lang/fr/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Utilisateur mis à jour avec succès', 'user_delete' => 'utilisateur supprimé', 'user_delete_notification' => 'Utilisateur supprimé avec succès', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'a créé un jeton API', diff --git a/lang/fr/auth.php b/lang/fr/auth.php index 61ca0ee3e60..a7fe59d0ccb 100644 --- a/lang/fr/auth.php +++ b/lang/fr/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Ces informations ne correspondent à aucun compte.', 'throttle' => 'Trop d\'essais, veuillez réessayer dans :seconds secondes.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'S\'inscrire', diff --git a/lang/fr/entities.php b/lang/fr/entities.php index fae7480b11c..fa0808912a7 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Afficher/masquer la barre latérale', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Étiquettes de la page', 'chapter_tags' => 'Étiquettes du chapitre', 'book_tags' => 'Étiquettes du livre', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index 664184a7d98..8ff81ba6bb3 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Configurer l\'authentification multi-facteurs ajoute une couche supplémentaire de sécurité à votre compte utilisateur.', 'users_mfa_x_methods' => ':count méthode configurée|:count méthodes configurées', 'users_mfa_configure' => 'Méthode de configuration', + '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' => 'Créer un nouveau jeton API', @@ -364,6 +367,7 @@ 'sk' => 'Slovaque', 'sl' => 'Slovène', 'sv' => 'Suédois', + 'th' => 'ภาษาไทย', 'tr' => 'Turc', 'uk' => 'Ukrainien', 'uz' => 'O‘zbekcha', diff --git a/lang/he/activities.php b/lang/he/activities.php index e94d53e0d37..52bf134d0c2 100644 --- a/lang/he/activities.php +++ b/lang/he/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'משתמש עודכן בהצלחה', 'user_delete' => 'משתמש נמחק', 'user_delete_notification' => 'משתמש הוסר בהצלחה', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'API Token נוצר', diff --git a/lang/he/auth.php b/lang/he/auth.php index 6f4f77223cf..6d3f04a61c2 100644 --- a/lang/he/auth.php +++ b/lang/he/auth.php @@ -8,6 +8,7 @@ 'failed' => 'פרטי ההתחברות אינם תואמים את הנתונים שלנו.', 'throttle' => 'נסיונות התחברות מהירים מדי, יש להמתין :seconds שניות ולנסות שנית.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'הרשמה למערכת', diff --git a/lang/he/entities.php b/lang/he/entities.php index 38ffc95cff0..12f92e1f046 100644 --- a/lang/he/entities.php +++ b/lang/he/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'תגיות דף', 'chapter_tags' => 'תגיות פרק', 'book_tags' => 'תגיות ספר', diff --git a/lang/he/settings.php b/lang/he/settings.php index b8f20481328..e816766813b 100644 --- a/lang/he/settings.php +++ b/lang/he/settings.php @@ -264,6 +264,9 @@ '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 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' => 'צור אסימון API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/hr/activities.php b/lang/hr/activities.php index bf79f9ab14a..06fec08d29d 100644 --- a/lang/hr/activities.php +++ b/lang/hr/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Korisnik je uspješno ažuriran', 'user_delete' => 'izbrisani korisnik', 'user_delete_notification' => 'Korisnik je uspješno uklonjen', + '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/hr/auth.php b/lang/hr/auth.php index bef07edcdaf..fcc81d5b075 100644 --- a/lang/hr/auth.php +++ b/lang/hr/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Ove vjerodajnice ne podudaraju se s našim zapisima.', 'throttle' => 'Previše pokušaja prijave. Molimo vas da pokušate za :seconds sekundi.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registrirajte se', diff --git a/lang/hr/entities.php b/lang/hr/entities.php index ff51c1b2f6f..320f85ecc50 100644 --- a/lang/hr/entities.php +++ b/lang/hr/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Oznake stranice', 'chapter_tags' => 'Oznake poglavlja', 'book_tags' => 'Oznake knjiga', diff --git a/lang/hr/settings.php b/lang/hr/settings.php index 60b2485efb1..a595b5b1605 100644 --- a/lang/hr/settings.php +++ b/lang/hr/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Postavite višestruku provjeru autentičnosti kao dodatni sloj sigurnosti za svoj korisnički račun.', 'users_mfa_x_methods' => ':count metoda konfigurirano|:count metode konfigurirane', 'users_mfa_configure' => 'Konfiguriraj Metode', + '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' => 'Stvori API token', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/hu/activities.php b/lang/hu/activities.php index 68184e597b4..a64af5a187e 100644 --- a/lang/hu/activities.php +++ b/lang/hu/activities.php @@ -67,9 +67,9 @@ 'auth_password_reset_request' => 'jelszó visszaállítást kért', 'auth_password_reset_update' => 'felhasználói jelszó visszaállítás', 'mfa_setup_method' => 'MFA módszert állított be', - 'mfa_setup_method_notification' => 'Többfaktoros azonosítás sikeresen beállítva', + 'mfa_setup_method_notification' => 'Többlépcsős azonosítás sikeresen beállítva', 'mfa_remove_method' => 'MFA módszert törölt', - 'mfa_remove_method_notification' => 'Többfaktoros azonosítás sikeresen törölve', + 'mfa_remove_method_notification' => 'Többlépcsős azonosítás sikeresen törölve', // Settings 'settings_update' => 'frissítette a beállításokat', @@ -86,9 +86,9 @@ // Imports 'import_create' => 'import elkészült', - 'import_create_notification' => 'Az import sikeresen feltöltötve', + 'import_create_notification' => 'Az import sikeresen feltöltve', 'import_run' => 'import frissítve', - 'import_run_notification' => 'A tartalmat sikeresen importáltam.', + 'import_run_notification' => 'A tartalom sikeresen importálva', 'import_delete' => 'import törölve', 'import_delete_notification' => 'Az import sikeresen törölve', @@ -99,14 +99,16 @@ 'user_update_notification' => 'Felhasználó sikeresen frissítve', 'user_delete' => 'felhasználót törölt', 'user_delete_notification' => 'Felhasználó sikeresen eltávolítva', + 'user_mfa_reset' => 'MFA alaphelyzetbe állítva felhasználónak', + 'user_mfa_reset_notification' => 'Többlépcsős azonosítási módok alaphelyzetbe állítva', // API Tokens - 'api_token_create' => 'létrehozta az API tokent', - 'api_token_create_notification' => 'API token sikeresen létrehozva', - 'api_token_update' => 'frissítette az API tokent', - 'api_token_update_notification' => 'API token sikeresen frissítve', - 'api_token_delete' => 'törölte az API tokent', - 'api_token_delete_notification' => 'API token sikeresen törölve', + 'api_token_create' => 'létrehozta az API kulcsot', + 'api_token_create_notification' => 'API kulcs sikeresen létrehozva', + 'api_token_update' => 'frissítette az API kulcsot', + 'api_token_update_notification' => 'API kulcs sikeresen frissítve', + 'api_token_delete' => 'törölte az API kulcsot', + 'api_token_delete_notification' => 'API kulcs sikeresen törölve', // Roles 'role_create' => 'szerepkört hozott létre', @@ -118,14 +120,14 @@ // Recycle Bin 'recycle_bin_empty' => 'kiürítette a lomtárat', - 'recycle_bin_restore' => 'lomtárból visszaállítva', - 'recycle_bin_destroy' => 'lomtárból törölve', + 'recycle_bin_restore' => 'visszaállított a lomtárból', + 'recycle_bin_destroy' => 'törölte a lomtárból', // Comments - 'commented_on' => 'megjegyzést fűzött hozzá:', - 'comment_create' => 'hozzáadott hozzászólás', - 'comment_update' => 'frissített hozzászólás', - 'comment_delete' => 'megjegyzés törlése', + 'commented_on' => 'megjegyzést fűzött hozzá', + 'comment_create' => 'hozzáadott egy hozzászólást', + 'comment_update' => 'frissített egy hozzászólást', + 'comment_delete' => 'törölt egy hozzászólást', // Sort Rules 'sort_rule_create' => 'létrehozta a rendezési szabályt', @@ -136,5 +138,5 @@ 'sort_rule_delete_notification' => 'Rendezési szabály sikeresen törölve', // Other - 'permissions_update' => 'engedélyek frissítve', + 'permissions_update' => 'frissítette ez engedélyeket', ]; diff --git a/lang/hu/auth.php b/lang/hu/auth.php index 90669e2fba2..4e3fef6b092 100644 --- a/lang/hu/auth.php +++ b/lang/hu/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Ezek a hitelesítő adatok nem egyeznek a rögzítettekkel.', 'throttle' => 'Túl sok bejelentkezési próbálkozás. :seconds múlva lehet újra megpróbálni.', + 'mfa_throttle' => 'Túl sok többlépcsős azonosítási próbálkozás. :seconds múlva lehet újra megpróbálni.', // Login & Register 'sign_up' => 'Regisztráció', @@ -33,85 +34,85 @@ 'social_registration_text' => 'Regisztráció és bejelentkezés másik szolgáltatással.', 'register_thanks' => 'Köszönjük a regisztrációt!', - 'register_confirm' => 'Ellenőrizze a megadott e-mail címet, és kattintson a megerősítő gombra :appName eléréséhez.', + 'register_confirm' => 'Ellenőrizze a megadott email címet, és kattintson a megerősítő gombra :appName eléréséhez.', 'registrations_disabled' => 'A regisztráció jelenleg le van tiltva', 'registration_email_domain_invalid' => 'Ebből az email tartományról nem lehet hozzáférni ehhez az alkalmazáshoz', 'register_success' => 'Köszönjük a regisztrációt! A regisztráció és a bejelentkezés megtörtént.', // Login auto-initiation 'auto_init_starting' => 'Bejelentkezési kísérlet', - 'auto_init_starting_desc' => 'Kapcsolatba lépünk az azonosítási rendszereddel, hogy elkezdjük a bejelentkezési folyamatot. Ha 5 másodperc után sem történik előrelépés, próbálkozhatsz az alábbi linkre kattintva.', + 'auto_init_starting_desc' => 'Kapcsolatba lépünk az azonosítási rendszerrel, hogy elkezdjük a bejelentkezési folyamatot. Ha 5 másodperc után sem történik előrelépés, próbálkozhat az alábbi linkre kattintva.', 'auto_init_start_link' => 'Folytatás azonosítással', // Password Reset 'reset_password' => 'Jelszó visszaállítása', - 'reset_password_send_instructions' => 'Adja meg az e-mail címet, amire a jelszó-visszaállító linket küldjük.', + 'reset_password_send_instructions' => 'Adja meg az email címet, amire a jelszó-visszaállító linket küldjük.', 'reset_password_send_button' => 'Visszaállító hivatkozás elküldése', - 'reset_password_sent' => 'A jelszó-visszaállító linket e-mailben fogjuk elküldeni a(z) :email címre, ha beállításra került a rendszerben.', + 'reset_password_sent' => 'A jelszó-visszaállító linket emailben fogjuk elküldeni a(z) :email címre, ha található felhasználó ezzel a címmel a rendszerben.', 'reset_password_success' => 'A jelszó sikeresen visszaállítva.', 'email_reset_subject' => ':appName jelszó visszaállítása', - 'email_reset_text' => 'Ezt az e-mailt azért küldtük, mert egy jelszó-visszaállításra vonatkozó kérést kaptunk ebből a fiókból.', + 'email_reset_text' => 'Ezt az emailt azért küldtük, mert egy jelszó-visszaállításra vonatkozó kérést kaptunk ebből a fiókból.', 'email_reset_not_requested' => 'Ha nem Ön kérte a jelszó visszaállítását, akkor nincs szükség további intézkedésre.', // Email Confirmation 'email_confirm_subject' => ':appName alkalmazásban beállított email címet meg kell erősíteni', 'email_confirm_greeting' => ':appName köszöni a csatlakozást!', - 'email_confirm_text' => 'Az email címet a lenti gombra kattintva lehet megerősíteni:', + 'email_confirm_text' => 'Kérjük erősítse meg email címét a lenti gombra kattintva:', 'email_confirm_action' => 'Email megerősítése', - 'email_confirm_send_error' => 'Az e-mail megerősítés kötelező, de a rendszer nem tudta elküldeni az e-mailt. Keresse fel az adminisztrátort, és gondoskodjon róla, hogy az e-mail helyesen van beállítva.', - 'email_confirm_success' => 'Az Ön e-mail címe sikeresen meg lett erősítve, most már be tud jelentkezni az e-mail címe használatával.', - 'email_confirm_resent' => 'Megerősítő e-mail újraküldve. Ellenőrizze a bejövő üzeneteit!', + 'email_confirm_send_error' => 'Az email megerősítés kötelező, de a rendszer nem tudta elküldeni az emailt. Keresse fel az adminisztrátort, és gondoskodjon róla, hogy az email helyesen van beállítva.', + 'email_confirm_success' => 'Az Ön email címe sikeresen meg lett erősítve, most már be tud jelentkezni az email címe használatával.', + 'email_confirm_resent' => 'Megerősítő email újraküldve. Ellenőrizze a bejövő üzeneteit!', 'email_confirm_thanks' => 'Köszönjük a megerősítést!', - 'email_confirm_thanks_desc' => 'Kérjük, várjon egy pillanatot, amíg a megerősítést kezeljük. Ha nem kerül átirányításra 3 másodperc után, kattintson a lenti "Folytatás" linkre a továbbhaladáshoz.', + 'email_confirm_thanks_desc' => 'Kérjük, várjon egy pillanatot, amíg a megerősítést kezeljük. Ha nem kerül átirányításra 3 másodpercen belül, kattintson a lenti "Folytatás" linkre a továbbhaladáshoz.', 'email_not_confirmed' => 'Az email cím nincs megerősítve', - 'email_not_confirmed_text' => 'Az email cím még nincs megerősítve.', - 'email_not_confirmed_click_link' => 'Kattintson a regisztráció után nem sokkal elküldött e-mailben található hivatkozásra.', + 'email_not_confirmed_text' => 'Az Ön email címe még nincs megerősítve.', + 'email_not_confirmed_click_link' => 'Kattintson a regisztráció után nem sokkal elküldött emailben található hivatkozásra.', 'email_not_confirmed_resend' => 'Ha nem érkezik meg a megerősítő email, a lenti űrlap beküldésével újra lehet küldeni.', 'email_not_confirmed_resend_button' => 'Megerősítő email újraküldése', // User Invite - 'user_invite_email_subject' => 'Ez egy meghívó :appName weboldalhoz!', - 'user_invite_email_greeting' => 'Létre lett hozva egy fiók az :appName weboldalon.', - 'user_invite_email_text' => 'Jelszó beállításához és hozzáféréshez a lenti gombra kell kattintani:', + 'user_invite_email_subject' => 'Meghívták a(z) :appName weboldalra!', + 'user_invite_email_greeting' => 'Létre lett hozva egy fiók a(z) :appName weboldalon.', + 'user_invite_email_text' => 'Jelszó beállításához és hozzáféréshez kattintson a lenti gombra:', 'user_invite_email_action' => 'Fiók jelszó beállítása', 'user_invite_page_welcome' => ':appName üdvözöl!', - 'user_invite_page_text' => 'A fiók véglegesítéséhez és a hozzáféréshez be kell állítani egy jelszót ami :appName weboldalon lesz használva a bejelentkezéshez.', + 'user_invite_page_text' => 'A fiók véglegesítéséhez és a hozzáféréshez be kell állítania egy jelszót ami a(z) :appName weboldalon lesz használva a bejelentkezéshez.', 'user_invite_page_confirm_button' => 'Jelszó megerősítése', - 'user_invite_success_login' => 'Jelszó beállítva. Most már be tudsz jelentkezni a beállított jelszóval a következő rendszerbe: :appName!', + 'user_invite_success_login' => 'Jelszó beállítva. Most már be tud jelentkezni a beállított jelszóval a(z) :appName rendszerbe!', // Multi-factor Authentication 'mfa_setup' => 'Többlépcsős azonosítás beállítása', 'mfa_setup_desc' => 'Állítsa be a többlépcsős azonosítást egy extra biztonsági rétegként a felhasználói fiókjához.', 'mfa_setup_configured' => 'Már beállítva', 'mfa_setup_reconfigure' => 'Újrakonfigurálás', - 'mfa_setup_remove_confirmation' => 'Biztosan ki szeretné kapcsolni a többlépcsős azonosítást?', + 'mfa_setup_remove_confirmation' => 'Biztosan el szeretné távolítani ezt a többlépcsős azonosítási módot?', 'mfa_setup_action' => 'Beállítások', 'mfa_backup_codes_usage_limit_warning' => 'Kevesebb, mint 5 visszaállítási kódja maradt. Kérem, hogy generáljon új kódokat, hogy csökkentse a rendszerből való kizárásának esélyét.', 'mfa_option_totp_title' => 'Mobilalkalmazás', - 'mfa_option_totp_desc' => 'A többlépcsős azonosításhoz olyan mobilalkalmazásra lesz szükséged, amely támogatja a TOTP-t, például a Google Authenticator, az Authy vagy a Microsoft Authenticator.', - 'mfa_option_backup_codes_title' => 'Visszaállítási kulcsok', + 'mfa_option_totp_desc' => 'A többlépcsős azonosításhoz olyan mobilalkalmazásra lesz szüksége, amely támogatja a TOTP-t, például a Google Authenticator, az Authy vagy a Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Visszaállítási kódok', 'mfa_option_backup_codes_desc' => 'Egyszer használatos biztonsági kódokat hoz létre, amelyeket bejelentkezéskor kell megadnia személyazonosságának igazolására. Ügyeljen arra, hogy ezeket biztonságos helyen tárolja.', 'mfa_gen_confirm_and_enable' => 'Jóváhagyás és engedélyezés', 'mfa_gen_backup_codes_title' => 'Visszaállítási kódok beállítása', - 'mfa_gen_backup_codes_desc' => 'Tárolja el egy biztonságos helyen az alábbi kódokat. Bejelentkezés során fel tudja használni őket másodlagos bejelentkezési kódként.', + 'mfa_gen_backup_codes_desc' => 'Tárolja el egy biztonságos helyen az alábbi kódokat! Bejelentkezés során fel tudja használni őket másodlagos bejelentkezési kódként.', 'mfa_gen_backup_codes_download' => 'Kódok letöltése', - 'mfa_gen_backup_codes_usage_warning' => 'A kódok egyszerhasználatosak', + 'mfa_gen_backup_codes_usage_warning' => 'A kódok egyszer használatosak', 'mfa_gen_totp_title' => 'Mobilalkalmazás beállítása', - 'mfa_gen_totp_desc' => 'A többlépcsős azonosításhoz olyan mobilalkalmazásra lesz szükséged, amely támogatja a TOTP-t, például a Google Authenticator, az Authy vagy a Microsoft Authenticator.', - 'mfa_gen_totp_scan' => 'Szkenneld be az alábbi QR-kódot az általad használt azonosító alkalmazásoddal, hogy használhasd az alkalmazást.', + 'mfa_gen_totp_desc' => 'A többlépcsős azonosításhoz olyan mobilalkalmazásra lesz szüksége, amely támogatja a TOTP-t, például a Google Authenticator, az Authy vagy a Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Olvassa be az alábbi QR-kódot az Ön által használt azonosító alkalmazással, hogy használhassa az alkalmazást.', 'mfa_gen_totp_verify_setup' => 'Beállítások ellenőrzése', - 'mfa_gen_totp_verify_setup_desc' => 'Ellenőrizd, hogy minden működik, azzal hogy beírod a kapott kódot amit az authentikátor alkalmazás generált az alábbi beviteli mezőbe:', - 'mfa_gen_totp_provide_code_here' => 'Add meg az alkalmazás által generált kódot ide', + 'mfa_gen_totp_verify_setup_desc' => 'Ellenőrizze, hogy minden működik azzal, hogy beírja az azonosító alkalmazás által generált kódot az alábbi mezőbe:', + 'mfa_gen_totp_provide_code_here' => 'Adja meg az alkalmazás által generált kódot ide', 'mfa_verify_access' => 'Hozzáférés ellenőrzése', - 'mfa_verify_access_desc' => 'Felhasználói fiókja megköveteli, hogy erősítse meg személyazonosságát egy további ellenőrzési szinttel, mielőtt hozzáférést kapna. A folytatáshoz használja az egyik konfigurált módszert.', - 'mfa_verify_no_methods' => 'Nincs konfigurálva MFA', - 'mfa_verify_no_methods_desc' => 'Nem található többlépcsős hitelesítési módszer a fiókjához. A hozzáféréshez legalább egy módszert be kell állítania.', + 'mfa_verify_access_desc' => 'Felhasználói fiókja megköveteli, hogy erősítse meg személyazonosságát egy további ellenőrzési szinttel, mielőtt hozzáférést kapna. A folytatáshoz használja az egyik beállított módot.', + 'mfa_verify_no_methods' => 'Nincsen beállítva mód', + 'mfa_verify_no_methods_desc' => 'Nem található többlépcsős hitelesítési mód a fiókjához. A hozzáféréshez legalább egy módot be kell állítania.', 'mfa_verify_use_totp' => 'Ellenőrzés mobil alkalmazás használatával', 'mfa_verify_use_backup_codes' => 'Ellenőrzés visszaállítási kóddal', 'mfa_verify_backup_code' => 'Visszaállítási kód', 'mfa_verify_backup_code_desc' => 'Adjon meg egy még fel nem használt visszaállítási kódot:', - 'mfa_verify_backup_code_enter_here' => 'Írd be a tartalék kódot', + 'mfa_verify_backup_code_enter_here' => 'Írja be a visszaállítási kódot', 'mfa_verify_totp_desc' => 'Írja be alább a mobilalkalmazásával generált kódot:', - 'mfa_setup_login_notification' => 'Többfaktoros hitelesítés konfigurálva. Kérjük, most jelentkezzen be újra a konfigurált módszerrel.', + 'mfa_setup_login_notification' => 'Többlépcsős hitelesítés beállítva. Kérjük, most jelentkezzen be újra a beállított módszerrel.', ]; diff --git a/lang/hu/common.php b/lang/hu/common.php index d25a765283d..080446892c2 100644 --- a/lang/hu/common.php +++ b/lang/hu/common.php @@ -20,7 +20,7 @@ 'description' => 'Leírás', 'role' => 'Szerepkör', 'cover_image' => 'Borítókép', - 'cover_image_description' => 'Ennek a képnek körülbelül 440 x 250 képpont méretűnek kell lennie, bár rugalmasan méretezhető és levágható, hogy a felhasználói felülethez illeszkedjen a különböző lehetőségek esetén, így a megjelenítés tényleges méretei eltérőek lesznek.', + 'cover_image_description' => 'Ennek a képnek körülbelül 440 x 250 pixel méretűnek kell lennie, bár rugalmasan méretezhető és levágható, hogy a felhasználói felülethez illeszkedjen a különböző alkalmazások esetén, így a megjelenítés tényleges méretei eltérőek lesznek.', // Actions 'actions' => 'Műveletek', @@ -53,7 +53,7 @@ 'filter_active' => 'Aktív szűrő:', 'filter_clear' => 'Szűrő törlése', 'download' => 'Letöltés', - 'open_in_tab' => 'Megnyitás új tab-on', + 'open_in_tab' => 'Megnyitás új fülön', 'open' => 'Megnyitás', // Sort Options @@ -70,7 +70,7 @@ 'deleted_user' => 'Törölt felhasználó', 'no_activity' => 'Nincs megjeleníthető aktivitás', 'no_items' => 'Nincsenek elérhető elemek', - 'back_to_top' => 'Oldal eleje', + 'back_to_top' => 'Oldal tetejére', 'skip_to_main_content' => 'Ugrás a fő tartalomra', 'toggle_details' => 'Részletek átkapcsolása', 'toggle_thumbnails' => 'Bélyegképek átkapcsolása', @@ -97,12 +97,12 @@ // Layout tabs 'tab_info' => 'Információ', - 'tab_info_label' => 'Tab: Másodlagos információk megjelenítése', + 'tab_info_label' => 'Fül: Másodlagos információk megjelenítése', 'tab_content' => 'Tartalom', - 'tab_content_label' => 'Tab: Elsődleges információk megjelenítése', + 'tab_content_label' => 'Fül: Elsődleges információk megjelenítése', // Email Content - 'email_action_help' => 'Probléma esetén a lenti ":actionText" gombra kell kattintani, majd ki kell másolni a lenti webcímet és be kell illeszteni egy böngészőbe:', + 'email_action_help' => 'Ha problémája van a(z) ":actionText" gombra kattintással, akkor másolja ki az URL-t, és illessze be a böngészőbe:', 'email_rights' => 'Minden jog fenntartva', // Footer Link Options diff --git a/lang/hu/components.php b/lang/hu/components.php index 6a54dd1b034..0b111df3844 100644 --- a/lang/hu/components.php +++ b/lang/hu/components.php @@ -9,8 +9,8 @@ 'image_list' => 'Képek listája', 'image_details' => 'A kép részletei', 'image_upload' => 'Kép feltöltése', - 'image_intro' => 'Itt választhatsz ki és kezelhetsz olyan képeket, amelyeket korábban feltöltöttek a rendszerbe.', - 'image_intro_upload' => 'Húzz ide egy új képfájlt az új kép feltöltéséhez, vagy használd a fenti "Kép feltöltése" gombot.', + 'image_intro' => 'Itt kiválaszthatja és kezelheti a rendszerbe korábban feltöltött képeket.', + 'image_intro_upload' => 'Húzzon ide egy új képfájlt az új kép feltöltéséhez, vagy használja a fenti "Kép feltöltése" gombot.', 'image_all' => 'Összes', 'image_all_title' => 'Összes kép megtekintése', 'image_book_title' => 'A könyvhöz feltöltött képek megtekintése', @@ -22,8 +22,8 @@ 'image_updated' => 'Frissítve ekkor: :updateDate', 'image_load_more' => 'Több betöltése', 'image_image_name' => 'Kép neve', - 'image_delete_used' => 'Ez a kép a lenti oldalakon van használatban.', - 'image_delete_confirm_text' => 'Biztosan törölhető ez a kép?', + 'image_delete_used' => 'Ez a kép az alábbi oldalakon van használatban.', + 'image_delete_confirm_text' => 'Biztosan törli ezt a képet?', 'image_select_image' => 'Kép kiválasztása', 'image_dropzone' => 'Képek feltöltése ejtéssel vagy kattintással', 'image_dropzone_drop' => 'Húzza a képeket ide a feltöltéshez', @@ -34,7 +34,7 @@ 'image_delete_success' => 'Kép sikeresen törölve', 'image_replace' => 'Kép cseréje', 'image_replace_success' => 'Képfájl sikeresen frissítve', - 'image_rebuild_thumbs' => 'Méret variációk újragenerálása', + 'image_rebuild_thumbs' => 'Méret változatok újragenerálása', 'image_rebuild_thumbs_success' => 'Kép méret változatok sikeresen újra lettek generálva!', // Code Editor diff --git a/lang/hu/editor.php b/lang/hu/editor.php index 705bc6d54c0..885e6951f8a 100644 --- a/lang/hu/editor.php +++ b/lang/hu/editor.php @@ -13,7 +13,7 @@ 'cancel' => 'Mégsem', 'save' => 'Mentés', 'close' => 'Bezárás', - 'apply' => 'Apply', + 'apply' => 'Alkalmaz', 'undo' => 'Visszavonás', 'redo' => 'Újra', 'left' => 'Balra', @@ -48,7 +48,7 @@ 'superscript' => 'Felső index', 'subscript' => 'Alsó index', 'text_color' => 'Szöveg szín', - 'highlight_color' => 'Highlight color', + 'highlight_color' => 'Kiemelő szín', 'custom_color' => 'Egyéni szín', 'remove_color' => 'Szín eltávolítása', 'background_color' => 'Háttérszín', @@ -65,14 +65,14 @@ 'insert_image' => 'Kép beszúrása', 'insert_image_title' => 'Kép beszúrása/szerkesztése', 'insert_link' => 'Hivatkozás beszúrása/szerkesztése', - 'insert_link_title' => 'Hivatkozás Beszúrása/Szerkesztése', + 'insert_link_title' => 'Hivatkozás beszúrása/szerkesztése', 'insert_horizontal_line' => 'Vízszintes vonal beszúrása', 'insert_code_block' => 'Kódrészlet beszúrása', - 'edit_code_block' => 'Kódrészlet beszúrása', + 'edit_code_block' => 'Kódrészlet szerkesztése', 'insert_drawing' => 'Rajz beszúrása/szerkesztése', 'drawing_manager' => 'Rajzkezelő', - 'insert_media' => 'Media beszúrása/szerkesztése', - 'insert_media_title' => 'Media Beszúrása/Szerkesztése', + 'insert_media' => 'Média beszúrása/szerkesztése', + 'insert_media_title' => 'Média beszúrása/szerkesztése', 'clear_formatting' => 'Formázás törlése', 'source_code' => 'Forráskód', 'source_code_title' => 'Forráskód', @@ -81,11 +81,11 @@ // Tables 'table_properties' => 'Táblázat tulajdonságai', - 'table_properties_title' => 'Táblázat Tulajdonságai', + 'table_properties_title' => 'Táblázat tulajdonságai', 'delete_table' => 'Táblázat törlése', - 'table_clear_formatting' => 'Tábla formázás törlése', + 'table_clear_formatting' => 'Táblázat formázás törlése', 'resize_to_contents' => 'Átméretezés a tartalomhoz', - 'row_header' => 'Sorfejléc', + 'row_header' => 'Fejléc sor', 'insert_row_before' => 'Sor beszúrása elé', 'insert_row_after' => 'Sor beszúrása mögé', 'delete_row' => 'Sor törlése', @@ -96,7 +96,7 @@ 'table_row' => 'Sor', 'table_column' => 'Oszlop', 'cell_properties' => 'Cella tulajdonságai', - 'cell_properties_title' => 'Cella Tulajdonságai', + 'cell_properties_title' => 'Cella tulajdonságai', 'cell_type' => 'Cella típusa', 'cell_type_cell' => 'Cella', 'cell_scope' => 'Hatáskör', @@ -111,7 +111,7 @@ 'border_style' => 'Szegély stílusa', 'border_color' => 'Szegély színe', 'row_properties' => 'Sor tulajdonságai', - 'row_properties_title' => 'Sor Tulajdonságai', + 'row_properties_title' => 'Sor tulajdonságai', 'cut_row' => 'Sor kivágása', 'copy_row' => 'Sor másolása', 'paste_row_before' => 'Sor beillesztése elé', @@ -125,10 +125,10 @@ 'copy_column' => 'Oszlop másolása', 'paste_column_before' => 'Oszlop beszúrása elé', 'paste_column_after' => 'Oszlop beszúrása utána', - 'cell_padding' => 'Cellatávolság', + 'cell_padding' => 'Cellamargó', 'cell_spacing' => 'Cellatávolság', 'caption' => 'Felirat', - 'show_caption' => 'Képaláírás mutatása', + 'show_caption' => 'Felirat megjelenítése', 'constrain' => 'Arányok megőrzése', 'cell_border_solid' => 'Folyamatos', 'cell_border_dotted' => 'Pontozott', @@ -145,33 +145,33 @@ 'source' => 'Forrás', 'alt_desc' => 'Alternatív leírás', 'embed' => 'Beágyazás', - 'paste_embed' => 'Illeszd be a beágyazási kódot ide:', + 'paste_embed' => 'Illessze be a beágyazási kódot ide:', 'url' => 'URL', - 'text_to_display' => 'Megjelenő szöveg', + 'text_to_display' => 'Megjelenítendő szöveg', 'title' => 'Cím', - 'browse_links' => 'Browse links', + 'browse_links' => 'Hivatkozások tallózása', 'open_link' => 'Hivatkozás megnyitása', 'open_link_in' => 'Hivatkozás megnyitása...', 'open_link_current' => 'Aktuális ablak', 'open_link_new' => 'Új ablak', 'remove_link' => 'Hivatkozás eltávolítása', - 'insert_collapsible' => 'Illeszd be az összecsukható blokkot', - 'collapsible_unwrap' => 'Kicsomagol', + 'insert_collapsible' => 'Összecsukható blokk beszúrása', + 'collapsible_unwrap' => 'Eltávolítás', 'edit_label' => 'Címke szerkesztése', - 'toggle_open_closed' => 'Nyitott/zárt váltása', + 'toggle_open_closed' => 'Nyitott/zárt állapot váltása', 'collapsible_edit' => 'Összecsukható blokk szerkesztése', - 'toggle_label' => 'Címke ki-be kapcsolása', + 'toggle_label' => 'Címke ki/be kapcsolása', // About view - 'about' => 'A szerkesztőről', + 'about' => 'A szerkesztő névjegye', 'about_title' => 'A WYSIWYG szerkesztőről', - 'editor_license' => 'Szerkesztő Licensz és Copyright információi', - '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_license' => 'Szerkesztő licenc és jogi információi', + 'editor_lexical_license' => 'Ez a szerkesztő az MIT licenc alatt szolgáltatott :lexicalLink segítségével készült.', + 'editor_lexical_license_link' => 'A teljes licenc az itt található.', 'editor_tiny_license' => 'Ez a szerkesztő az MIT licenc alatt szolgáltatott :tinyLink segítségével készült.', 'editor_tiny_license_link' => 'A TinyMCE szerzői jogi és licencinformációi itt találhatók.', - 'save_continue' => 'Mentés és Folytatás', - 'callouts_cycle' => '(Folyamatos lenyomva tartással válassza ki a típusok közötti váltást)', + 'save_continue' => 'Oldal mentése és folytatás', + 'callouts_cycle' => '(Ismételt lenyomással váltogathat a típusok között)', 'link_selector' => 'Tartalom hivatkozása', 'shortcuts' => 'Gyorsbillentyűk', 'shortcut' => 'Gyorsbillentyű', diff --git a/lang/hu/entities.php b/lang/hu/entities.php index 707de62d871..b499a9b6b72 100644 --- a/lang/hu/entities.php +++ b/lang/hu/entities.php @@ -44,45 +44,45 @@ 'default_template_explain' => 'Rendeljen hozzá egy oldalsablont, amely alapértelmezett tartalomként lesz használva az ezen az elemen belül létrehozott összes oldalon. Ne feledje, hogy ezt csak akkor használja, ha az oldal készítője megtekintési hozzáféréssel rendelkezik a kiválasztott sablonoldalhoz.', 'default_template_select' => 'Válasszon ki egy oldalsablont', 'import' => 'Import', - 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', - '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', + 'import_validate' => 'Importált adatok ellenőrzése', + 'import_desc' => 'Importáljon könyveket, fejezeteket és oldalakat egy azonos, vagy egy másik alkalmazásból származó ZIP állományból. A fájl feltöltése és ellenőrzése után beállíthatja és elindíthatja az importálást a következő oldalon.', + 'import_zip_select' => 'Feltöltendő ZIP fájl kiválasztása', + 'import_zip_validation_errors' => 'A megadott ZIP fájlban hibák találhatók:', + 'import_pending' => 'Függő importálások', + 'import_pending_none' => 'Nincs elindított importálás.', + 'import_continue' => 'Importálás folytatása', + 'import_continue_desc' => 'Importálás előtt nézze át a ZIP fájlból betöltendő tartalmakat. Amikor készen áll, futtassa le az importálást, hogy hozzáadja az állományokat a rendszerhez. A feltöltött ZIP fájl az importálás után automatikusan törlésre kerül.', + 'import_details' => 'Importálás részletei', + 'import_run' => 'Importálás', + 'import_size' => ':size méretű ZIP', + 'import_uploaded_at' => 'Feltöltve:', + 'import_uploaded_by' => 'Feltöltötte:', + 'import_location' => 'Importálás helye', + 'import_location_desc' => 'Válasszon ki egy helyet az importált tartalomnak. Létrehozási jogosultsággal kell rendelkezni a kiválasztott helyen.', + 'import_delete_confirm' => 'Biztosan törölni akarja ezt az importálást?', + 'import_delete_desc' => 'Ez véglegesen el fogja távolítani a feltöltött ZIP fájlt.', + 'import_errors' => 'Importálási hibák', + 'import_errors_desc' => 'A következő hibák jelentkeztek az importálás során:', + 'breadcrumb_siblings_for_page' => 'Navigáció az oldal szomszédaihoz', + 'breadcrumb_siblings_for_chapter' => 'Navigáció a fejezet szomszédaihoz', + 'breadcrumb_siblings_for_book' => 'Navigáció a könyv szomszédaihoz', + 'breadcrumb_siblings_for_bookshelf' => 'Navigáció a polc szomszédaihoz', // Permissions and restrictions 'permissions' => 'Jogosultságok', 'permissions_desc' => 'Itt állítsa be az engedélyeket a felhasználói szerepkörök által biztosított alapértelmezett engedélyek felülbírálásához.', - 'permissions_book_cascade' => 'A könyvekre beállított engedélyek automatikusan az alárendelt fejezetekhez és oldalakhoz kapcsolódnak, kivéve, ha saját engedélyekkel rendelkeznek.', - 'permissions_chapter_cascade' => 'A fejezetekre beállított engedélyek automatikusan az alárendelt oldalakra lépnek át, hacsak nem rendelkeznek saját engedélyekkel.', + 'permissions_book_cascade' => 'A könyvekre beállított engedélyek automatikusan az alárendelt fejezetekre és oldalakra is érvényesek, kivéve, ha azok saját engedély beállításokkal rendelkeznek.', + 'permissions_chapter_cascade' => 'A fejezetekre beállított engedélyek automatikusan az alárendelt oldalakra is érvényesek, kivéve, ha azok saját engedély beállításokkal rendelkeznek.', 'permissions_save' => 'Jogosultságok mentése', 'permissions_owner' => 'Tulajdonos', 'permissions_role_everyone_else' => 'Mindenki más', - 'permissions_role_everyone_else_desc' => 'Állítson be engedélyeket az összes, kifejezetten nem felülírt szerepkörhöz.', + 'permissions_role_everyone_else_desc' => 'Állítson be engedélyeket az összes nem felülírt szerepkörhöz.', 'permissions_role_override' => 'A szerepkör engedélyeinek felülbírálása', - 'permissions_inherit_defaults' => 'Alapértelmezett értékek öröklése', + 'permissions_inherit_defaults' => 'Alapértelmezett engedélyek öröklése', // Search 'search_results' => 'Keresési eredmények', - 'search_total_results_found' => ':count találat|összesen :count találat', + 'search_total_results_found' => ':count találat|Összesen :count találat', 'search_clear' => 'Keresés törlése', 'search_no_pages' => 'Nincsenek a keresésnek megfelelő oldalak', 'search_for_term' => ':term keresése', @@ -110,7 +110,7 @@ // Shelves 'shelf' => 'Polc', 'shelves' => 'Polcok', - 'x_shelves' => ':count polc|:count polcok', + 'x_shelves' => ':count polc|:count polc', 'shelves_empty' => 'Nincsenek könyvespolcok létrehozva', 'shelves_create' => 'Új polc létrehozása', 'shelves_popular' => 'Népszerű polcok', @@ -121,24 +121,24 @@ 'shelves_save' => 'Polc mentése', 'shelves_books' => 'Könyvek ezen a polcon', 'shelves_add_books' => 'Könyvek hozzáadása ehhez a polchoz', - 'shelves_drag_books' => 'Könyveket áthúzással lehet elhelyezni ezen a polcon', + 'shelves_drag_books' => 'Húzzon ide könyveket a polchoz hozzáadáshoz', 'shelves_empty_contents' => 'Ehhez a polchoz nincsenek könyvek rendelve', 'shelves_edit_and_assign' => 'Polc szerkesztése könyvek hozzárendeléséhez', 'shelves_edit_named' => ':name polc szerkesztése', 'shelves_edit' => 'Polc szerkesztése', 'shelves_delete' => 'Polc törlése', 'shelves_delete_named' => ':name polc törlése', - 'shelves_delete_explain' => "':name'. nevű polc ezzel le lesz törölve. A benne található könyvek nem lesznek törölve.", - 'shelves_delete_confirmation' => 'Biztosan törölhető ez a polc?', - 'shelves_permissions' => 'Polc jogosultság', + 'shelves_delete_explain' => "Ez törölni fogja a(z) ':name' nevű polcot. A benne található könyvek nem lesznek törölve.", + 'shelves_delete_confirmation' => 'Biztosan törli ezt a polcot?', + 'shelves_permissions' => 'Polc jogosultságok', 'shelves_permissions_updated' => 'Polc jogosultságok frissítve', 'shelves_permissions_active' => 'Polc jogosultságok aktívak', - 'shelves_permissions_cascade_warning' => 'A polcokhoz kapcsolódó jogosultságok nem kapcsolódnak automatikusan a tárolt könyvekhez. Ennek az az oka, hogy egy könyv több polcon is létezhet. Az engedélyek azonban lemásolhatók a gyermekkönyvekbe az alábbi lehetőség segítségével.', - 'shelves_permissions_create' => 'A polclétrehozási jogosultságok csak az alárendelt könyvekbe való másoláshoz használhatók az alábbi művelettel. Nem szabályozzák a könyvek létrehozásának lehetőségét.', - 'shelves_copy_permissions_to_books' => 'Jogosultság másolása könyvekre', - 'shelves_copy_permissions' => 'Jogosultság másolása', - 'shelves_copy_permissions_explain' => 'Ezzel a polc jelenlegi engedélybeállításait alkalmazza a benne található összes könyvre. Az aktiválás előtt győződjön meg arról, hogy a polc engedélyeinek módosításait elmentette.', - 'shelves_copy_permission_success' => 'Könyvespolc jogosultságok átmásolva :count könyvre', + 'shelves_permissions_cascade_warning' => 'A polcokhoz kapcsolódó jogosultságok nem vonatkoznak automatikusan a hozzájuk rendelt könyvekre. Ennek az az oka, hogy egy könyv több polchoz is tartozhat. Az engedélyek azonban lemásolhatók a könyvekre az alábbi opcióval.', + 'shelves_permissions_create' => 'A polc létrehozási jogosultságok csak a jogosultságok a hozzárendelt könyvekre másolásához vannak használva. Nem szabályozzák a könyvek létrehozásának lehetőségét.', + 'shelves_copy_permissions_to_books' => 'Jogosultságok másolása könyvekre', + 'shelves_copy_permissions' => 'Jogosultságok másolása', + 'shelves_copy_permissions_explain' => 'Ezzel alkalmazza a polc jelenlegi engedély beállításait a benne található összes könyvre. Az aktiválás előtt győződjön meg arról, hogy a polc engedélyeinek módosításait elmentette.', + 'shelves_copy_permission_success' => 'Polc jogosultságok átmásolva :count könyvre', // Books 'book' => 'Könyv', @@ -154,8 +154,8 @@ 'books_create' => 'Új könyv létrehozása', 'books_delete' => 'Könyv törlése', 'books_delete_named' => ':bookName könyv törlése', - 'books_delete_explain' => '\':bookName\' nevű könyv törölve lesz. Minden oldal és fejezet el lesz távolítva.', - 'books_delete_confirmation' => 'Biztosan törölhető ez a könyv?', + 'books_delete_explain' => 'Ez törölni fogja a(z) \':bookName\' nevű könyvet. Minden oldal és fejezet el lesz távolítva.', + 'books_delete_confirmation' => 'Biztosan törli ezt a könyvet?', 'books_edit' => 'Könyv szerkesztése', 'books_edit_named' => ':bookName könyv szerkesztése', 'books_form_book_name' => 'Könyv neve', @@ -170,10 +170,10 @@ 'books_search_this' => 'Keresés ebben a könyvben', 'books_navigation' => 'Könyv navigáció', 'books_sort' => 'Könyv tartalmak rendezése', - '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_desc' => 'Rendezze át egy könyv tartalmát a fejezetek és oldalak mozgatásával. Más könyvek is hozzáadhatók, így könnyű az átmozgatás könyvek között is. Opcionálisan egy rendezési szabály is megadható, hogy minden változtatáskor automatikusan rendezze a könyv tartalmát.', + 'books_sort_auto_sort' => 'Automatikus rendezés opció', + 'books_sort_auto_sort_active' => 'Aktív automatikus rendezés: :sortName', + 'books_sort_auto_sort_creation_hint' => 'Automatikus rendezési szabályok a "Listák és rendezés" beállításoknál hozhatók létre egy megfelelő jogosultságokkal rendelkező felhasználó által.', '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', @@ -181,7 +181,7 @@ 'books_sort_chapters_first' => 'Fejezetek elől', 'books_sort_chapters_last' => 'Fejezetek hátul', 'books_sort_show_other' => 'Egyéb könyvek mutatása', - 'books_sort_save' => 'Új elrendezés mentése', + 'books_sort_save' => 'Új sorrend mentése', 'books_sort_show_other_desc' => 'Adjon hozzá más könyveket, hogy bevonja őket a rendezési műveletbe, és lehetővé tegye a könyvek közötti egyszerű átszervezést.', 'books_sort_move_up' => 'Mozgatás fel', 'books_sort_move_down' => 'Mozgatás le', @@ -191,7 +191,7 @@ 'books_sort_move_next_chapter' => 'Mozgatás a következő fejezetbe', 'books_sort_move_book_start' => 'Mozgatás a könyv elejére', 'books_sort_move_book_end' => 'Mozgatás a könyv végére', - 'books_sort_move_before_chapter' => 'Morgazás a fejezet elé', + 'books_sort_move_before_chapter' => 'Mozgatás a fejezet elé', 'books_sort_move_after_chapter' => 'Mozgatás a fejezet után', 'books_copy' => 'Könyv másolása', 'books_copy_success' => 'Könyv sikeresen lemásolva', @@ -199,14 +199,14 @@ // Chapters 'chapter' => 'Fejezet', 'chapters' => 'Fejezetek', - 'x_chapters' => ':count fejezet|:count fejezetek', + 'x_chapters' => ':count fejezet|:count fejezet', 'chapters_popular' => 'Népszerű fejezetek', 'chapters_new' => 'Új fejezet', 'chapters_create' => 'Új fejezet létrehozása', 'chapters_delete' => 'Fejezet törlése', 'chapters_delete_named' => ':chapterName fejezet törlése', - 'chapters_delete_explain' => 'A(z) \':chapterName\' törlésére készül. A fejezethez tartozó minden oldal is törlésre fog kerülni.', - 'chapters_delete_confirm' => 'Biztosan törölhető ez a fejezet?', + 'chapters_delete_explain' => 'Ez törölni fogja a(z) \':chapterName\' fejezetet. A fejezethez tartozó minden oldal is törlésre fog kerülni.', + 'chapters_delete_confirm' => 'Biztosan törli ezt a fejezetet?', 'chapters_edit' => 'Fejezet szerkesztése', 'chapters_edit_named' => ':chapterName fejezet szerkesztése', 'chapters_save' => 'Fejezet mentése', @@ -235,32 +235,32 @@ 'pages_delete_draft' => 'Vázlat oldal törlése', 'pages_delete_success' => 'Oldal törölve', 'pages_delete_draft_success' => 'Vázlat oldal törölve', - 'pages_delete_warning_template' => 'Ez az oldal aktívan használatban van könyv vagy fejezet alapértelmezett oldalsablonjaként. Ezekhez a könyvekhez vagy fejezetekhez a továbbiakban nem lesz alapértelmezett oldalsablon hozzárendelve az oldal törlése után.', - 'pages_delete_confirm' => 'Biztosan törölhető ez az oldal?', - 'pages_delete_draft_confirm' => 'Biztosan törölhető ez a vázlatoldal?', + 'pages_delete_warning_template' => 'Ez az oldal aktívan használatban van egy könyv vagy egy fejezet alapértelmezett oldalsablonjaként. Ezekhez a könyvekhez vagy fejezetekhez nem lesz alapértelmezett oldalsablon hozzárendelve az oldal törlése után.', + 'pages_delete_confirm' => 'Biztosan törli ezt az oldalt?', + 'pages_delete_draft_confirm' => 'Biztosan törli ezt az vázlat oldalt?', 'pages_editing_named' => ':pageName oldal szerkesztése', - 'pages_edit_draft_options' => 'Vázlatbeállítások', + 'pages_edit_draft_options' => 'Vázlat beállítások', 'pages_edit_save_draft' => 'Vázlat mentése', 'pages_edit_draft' => 'Oldal vázlat szerkesztése', 'pages_editing_draft' => 'Vázlat szerkesztése', 'pages_editing_page' => 'Oldal szerkesztése', - 'pages_edit_draft_save_at' => 'Vázlat elmentve:', + 'pages_edit_draft_save_at' => 'Vázlat elmentve: ', 'pages_edit_delete_draft' => 'Vázlat törlése', - 'pages_edit_delete_draft_confirm' => 'Biztos benne, hogy törölni kívánja az oldalmódosítások piszkozatát? Az utolsó teljes mentés óta végrehajtott összes módosítása elvész, és a szerkesztő frissül a legfrissebb, nem vázlatos mentési állapottal.', + 'pages_edit_delete_draft_confirm' => 'Biztos benne, hogy törli az oldalmódosítások vázlatát? Az utolsó teljes mentés óta végrehajtott összes módosítása elvész, és a szerkesztő frissül a legfrissebb, nem vázlatos mentési állapottal.', 'pages_edit_discard_draft' => 'Vázlat elvetése', 'pages_edit_switch_to_markdown' => 'Váltás Markdown szerkesztőre', - 'pages_edit_switch_to_markdown_clean' => '(Tisztított tartalom)', + 'pages_edit_switch_to_markdown_clean' => '(Tiszta tartalom)', 'pages_edit_switch_to_markdown_stable' => '(Stabil tartalom)', 'pages_edit_switch_to_wysiwyg' => 'Váltás a WYSIWYG szerkesztőre', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Váltás az új WYSIWYG szerkeztőre', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(Béta tesztelés alatt)', 'pages_edit_set_changelog' => 'Változásnapló beállítása', 'pages_edit_enter_changelog_desc' => 'A végrehajtott módosítások rövid leírása', 'pages_edit_enter_changelog' => 'Változásnapló megadása', 'pages_editor_switch_title' => 'Szerkesztőváltás', 'pages_editor_switch_are_you_sure' => 'Biztosan módosítani szeretné ennek az oldalnak a szerkesztőjét?', - 'pages_editor_switch_consider_following' => 'A szerkesztők módosításakor vegye figyelembe a következőket:', - 'pages_editor_switch_consideration_a' => 'Mentés után az új szerkesztő opciót minden jövőbeli szerkesztő használni fogja, beleértve azokat is, amelyek esetleg nem tudják maguk módosítani a szerkesztő típusát.', + 'pages_editor_switch_consider_following' => 'A szerkesztők váltásakor vegye figyelembe a következőket:', + 'pages_editor_switch_consideration_a' => 'Mentés után az új szerkesztő opciót fogja minden jövőbeli szerkesztő használni, beleértve azokat is, akik esetleg nem tudják maguk módosítani a szerkesztő típusát.', 'pages_editor_switch_consideration_b' => 'Ez bizonyos körülmények között a részletek és a szintaxis elvesztéséhez vezethet.', 'pages_editor_switch_consideration_c' => 'A legutóbbi mentés óta végrehajtott címke- vagy változásnapló-módosítások nem maradnak fenn a módosítás során.', 'pages_save' => 'Oldal mentése', @@ -269,11 +269,11 @@ 'pages_md_editor' => 'Szerkesztő', 'pages_md_preview' => 'Előnézet', 'pages_md_insert_image' => 'Kép beillesztése', - 'pages_md_insert_link' => 'Entitás hivatkozás beillesztése', + 'pages_md_insert_link' => 'Belső hivatkozás beillesztése', 'pages_md_insert_drawing' => 'Rajz beillesztése', 'pages_md_show_preview' => 'Előnézet megjelenítése', 'pages_md_sync_scroll' => 'Előnézet pozíció szinkronizálása', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => 'Sima szöveg szerkesztő', 'pages_drawing_unsaved' => 'Nem mentett rajz található', 'pages_drawing_unsaved_confirm' => 'A rendszer nem mentett rajzadatokat talált egy korábbi sikertelen rajzmentési kísérletből. Szeretné visszaállítani és folytatni a nem mentett rajz szerkesztését?', 'pages_not_in_chapter' => 'Az oldal nincs fejezetben', @@ -285,7 +285,7 @@ 'pages_permissions_success' => 'Oldal jogosultságok frissítve', 'pages_revision' => 'Változat', 'pages_revisions' => 'Oldal változatai', - 'pages_revisions_desc' => 'Az alábbiakban az oldal összes korábbi verziója látható. Visszatekinthet, összehasonlíthatja és visszaállíthatja a régi oldalverziókat, ha az engedélyek lehetővé teszik. Előfordulhat, hogy az oldal teljes előzménye itt nem jelenik meg teljes mértékben, mivel a rendszerkonfigurációtól függően a régi változatok automatikusan törlődnek.', + 'pages_revisions_desc' => 'Az alábbiakban az oldal összes korábbi verziója látható. Visszanézheti, összehasonlíthatja és visszaállíthatja a régi verziókat, ha az engedélyek lehetővé teszik. Előfordulhat, hogy az oldal teljes előzménye itt nem jelenik meg, mivel a beállításoktól függően a régi változatok automatikusan törlődhetnek.', 'pages_revisions_named' => ':pageName oldal változatai', 'pages_revision_named' => ':pageName oldal változata', 'pages_revision_restored_from' => 'Visszaállítva innen: #:id; :summary', @@ -293,8 +293,8 @@ 'pages_revisions_date' => 'Változat dátuma', 'pages_revisions_number' => '#', 'pages_revisions_sort_number' => 'Változat száma', - 'pages_revisions_numbered' => 'Változat #:id', - 'pages_revisions_numbered_changes' => '#:id változat módosításai', + 'pages_revisions_numbered' => '#:id. változat', + 'pages_revisions_numbered_changes' => '#:id. változat módosításai', 'pages_revisions_editor' => 'Szerkesztő típusa', 'pages_revisions_changelog' => 'Változásnapló', 'pages_revisions_changes' => 'Módosítások', @@ -304,12 +304,12 @@ 'pages_revisions_none' => 'Ennek az oldalnak nincsenek változatai', 'pages_copy_link' => 'Hivatkozás másolása', 'pages_edit_content_link' => 'Ugrás a szakaszhoz a szerkesztőben', - 'pages_pointer_enter_mode' => 'Lépjen be a szakaszválasztó módba', + 'pages_pointer_enter_mode' => 'Belépés szakaszválasztó módba', 'pages_pointer_label' => 'Oldalszakasz beállításai', 'pages_pointer_permalink' => 'Oldalszakasz állandó hivatkozás', 'pages_pointer_include_tag' => 'Oldalszakasz tartalmazza a címkét', - 'pages_pointer_toggle_link' => 'Permalink mód, Nyomja meg az include tag megjelenítéséhez', - 'pages_pointer_toggle_include' => 'Include tag mód, Nyomja meg az permalink megjelenítéséhez', + 'pages_pointer_toggle_link' => 'Állandó hivatkozás mód. Nyomja meg az beágyazó címke megjelenítéséhez!', + 'pages_pointer_toggle_include' => 'Beágyazó címke mód. Nyomja meg az állandó hivatkozás megjelenítéséhez!', 'pages_permissions_active' => 'Oldal jogosultságok aktívak', 'pages_initial_revision' => 'Kezdeti közzététel', 'pages_references_update_revision' => 'A belső hivatkozások automatikus frissítése', @@ -318,36 +318,39 @@ 'pages_draft_edited_notification' => 'Ezt az oldalt azóta már frissítették. Javasolt ennek a vázlatnak az elvetése.', 'pages_draft_page_changed_since_creation' => 'Ez az oldal a vázlat létrehozása óta frissült. Javasoljuk, hogy dobja el ezt a piszkozatot, vagy ügyeljen arra, hogy ne írja felül az oldal módosításait.', 'pages_draft_edit_active' => [ - 'start_a' => ':count felhasználók kezdte el szerkeszteni ezt az oldalt', + 'start_a' => ':count felhasználó kezdte el szerkeszteni ezt az oldalt', 'start_b' => ':userName elkezdte szerkeszteni ezt az oldalt', 'time_a' => 'mióta az oldal utoljára frissítve volt', 'time_b' => 'az utolsó :minCount percben', - 'message' => ':start :time. Ügyeljen arra, hogy ne írjuk felül egymás frissítéseit!', + 'message' => ':start :time. Ügyeljen arra, hogy ne írja felül mások frissítéseit!', ], - 'pages_draft_discarded' => 'Vázlat elvetve! A szerkesztő frissítve lesz az oldal aktuális tartalmával', - 'pages_draft_deleted' => 'Vázlat elvetve! A szerkesztő frissítve lesz az oldal aktuális tartalmával', + 'pages_draft_discarded' => 'Vázlat elvetve! A szerkesztő frissítve lett az oldal aktuális tartalmával', + 'pages_draft_deleted' => 'Vázlat törölve! A szerkesztő frissítve lett az oldal aktuális tartalmával', 'pages_specific' => 'Egy bizonyos oldal', 'pages_is_template' => 'Oldalsablon', // Editor Sidebar 'toggle_sidebar' => 'Oldalsáv ki/be', + 'page_contents' => 'Oldal tartalma', + 'page_contents_none' => 'Nincsen egy címsor sem az oldalon.', + 'page_contents_info' => 'A tartalom menü automatikusan generálódik az oldalon használt címsorokból.', 'page_tags' => 'Oldal címkék', 'chapter_tags' => 'Fejezet címkék', 'book_tags' => 'Könyv címkék', 'shelf_tags' => 'Polc címkék', 'tag' => 'Címke', 'tags' => 'Címkék', - 'tags_index_desc' => 'A címkék a rendszeren belüli tartalomra alkalmazhatók a kategorizálás rugalmas formája alkalmazása érdekében. A címkéknek kulcsuk és értékük is lehetnek, de az érték nem kötelező. Alkalmazása után a tartalom lekérdezhető a címkenév és érték használatával.', + 'tags_index_desc' => 'A rendszeren belül a tartalomra címkék helyezhetők a rugalmasan rendezhetőség érdekében. A címkéknek neve és értéke is lehet, de az érték nem kötelező. Beállítás után a tartalom lekérdezhető a címkenév és érték használatával.', 'tag_name' => 'Címkenév', 'tag_value' => 'Címke érték (nem kötelező)', 'tags_explain' => "Címkék hozzáadása a tartalom jobb kategorizálásához.\nA mélyebb szervezettség megvalósításához hozzá lehet rendelni egy értéket a címkéhez.", 'tags_add' => 'Másik címke hozzáadása', 'tags_remove' => 'Címke eltávolítása', - 'tags_usages' => 'Összes címkehasználat', - 'tags_assigned_pages' => 'Oldalakhoz Rendelt', + 'tags_usages' => 'Összes címke használat', + 'tags_assigned_pages' => 'Oldalakhoz rendelt', 'tags_assigned_chapters' => 'Fejezetekhez rendelt', - 'tags_assigned_books' => 'Könyvekhez Rendelt', - 'tags_assigned_shelves' => 'Polcokhoz Rendelt', + 'tags_assigned_books' => 'Könyvekhez rendelt', + 'tags_assigned_shelves' => 'Polcokhoz rendelt', 'tags_x_unique_values' => ':count egyedi érték', 'tags_all_values' => 'Összes érték', 'tags_view_tags' => 'Címkék megtekintése', @@ -356,12 +359,12 @@ 'attachments' => 'Csatolmányok', 'attachments_explain' => 'Az oldalon megjelenő fájlok feltöltése vagy hivatkozások csatolása. Az oldal oldalsávjában fognak megjelenni.', 'attachments_explain_instant_save' => 'Az itt történt módosítások azonnal el lesznek mentve.', - 'attachments_upload' => 'Fájlfeltöltés', + 'attachments_upload' => 'Fáj lfeltöltése', 'attachments_link' => 'Hivatkozás csatolása', 'attachments_upload_drop' => 'Alternatív megoldásként a fájlt ide húzva is fel lehet tölteni mellékletként.', 'attachments_set_link' => 'Hivatkozás beállítása', - 'attachments_delete' => 'Biztosan törölhető ez a melléklet?', - 'attachments_dropzone' => 'Húzza a file(oka)t ide a feltöltéshez', + 'attachments_delete' => 'Biztosan törli a mellékletet?', + 'attachments_dropzone' => 'Húzza ide a fájlokat a feltöltéshez', 'attachments_no_files' => 'Nincsenek fájlok feltöltve', 'attachments_explain_link' => 'Fájl feltöltése helyett hozzá lehet kapcsolni egy hivatkozást. Ez egy hivatkozás lesz egy másik oldalra vagy egy fájlra a felhőben.', 'attachments_link_name' => 'Hivatkozás neve', @@ -369,12 +372,12 @@ 'attachments_link_url' => 'Hivatkozás fájlra', 'attachments_link_url_hint' => 'Weboldal vagy fájl webcíme', 'attach' => 'Csatolás', - 'attachments_insert_link' => 'Melléklet hivatkozás hozzáadása oldalhoz', + 'attachments_insert_link' => 'Melléklet hivatkozás hozzáadása az oldalhoz', 'attachments_edit_file' => 'Fájl szerkesztése', 'attachments_edit_file_name' => 'Fájl neve', 'attachments_edit_drop_upload' => 'Feltöltés és felülírás ejtéssel vagy kattintással', 'attachments_order_updated' => 'Csatolmány sorrend frissítve', - 'attachments_updated_success' => 'Csatolmány részletei frissítve', + 'attachments_updated_success' => 'Csatolmány részletek frissítve', 'attachments_deleted' => 'Csatolmány törölve', 'attachments_file_uploaded' => 'Fájl sikeresen feltöltve', 'attachments_file_updated' => 'Fájl sikeresen frissítve', @@ -387,7 +390,7 @@ 'templates_prepend_content' => 'Hozzáadás az oldal tartalmának elejéhez', // Profile View - 'profile_user_for_x' => 'Felhasználó ez óta: :time', + 'profile_user_for_x' => 'Felhasználó ennyi ideje: :time', 'profile_created_content' => 'Létrehozott tartalom', 'profile_not_created_pages' => ':userName még nem hozott létre oldalt', 'profile_not_created_chapters' => ':userName még nem hozott létre fejezetet', @@ -395,61 +398,61 @@ 'profile_not_created_shelves' => ':userName még nem hozott létre polcot', // Comments - 'comment' => 'Megjegyzés', - 'comments' => 'Megjegyzések', - 'comment_add' => 'Megjegyzés hozzáadása', - 'comment_none' => 'No comments to display', - 'comment_placeholder' => 'Megjegyzés írása', - 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', - 'comment_archived_threads' => 'Archived Threads', - 'comment_save' => 'Megjegyzés mentése', - 'comment_new' => 'Új megjegyzés', - 'comment_created' => 'megjegyzést fűzött hozzá :createDiff', + 'comment' => 'Hozzászólás', + 'comments' => 'Hozzászólások', + 'comment_add' => 'Hozzászólás hozzáadása', + 'comment_none' => 'Nincs megjeleníthető hozzászólás', + 'comment_placeholder' => 'Hozzászólás írása', + 'comment_thread_count' => ':count hozzászóláslánc|:count hozzászóláslánc', + 'comment_archived_count' => ':count archivált', + 'comment_archived_threads' => 'Archivált hozzászólásláncok', + 'comment_save' => 'Hozzászólás mentése', + 'comment_new' => 'Új hozzászólás', + 'comment_created' => 'hozzászólt :createDiff', 'comment_updated' => 'Frissítve :updateDiff :username által', 'comment_updated_indicator' => 'Frissített', - 'comment_deleted_success' => 'Megjegyzés törölve', - 'comment_created_success' => 'Megjegyzés hozzáadva', - 'comment_updated_success' => 'Megjegyzés frissítve', - 'comment_archive_success' => 'Comment archived', - 'comment_unarchive_success' => 'Comment un-archived', - 'comment_view' => 'View comment', - 'comment_jump_to_thread' => 'Jump to thread', - 'comment_delete_confirm' => 'Biztosan törölhető ez a megjegyzés?', - 'comment_in_reply_to' => 'Válasz erre: :commentId', - 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', - 'comment_editor_explain' => 'Itt vannak az ezen az oldalon lévő megjegyzések. Megjegyzések hozzáadhatók és kezelhetők a mentett oldal megtekintésekor.', + 'comment_deleted_success' => 'Hozzászólás törölve', + 'comment_created_success' => 'Hozzászólás hozzáadva', + 'comment_updated_success' => 'Hozzászólás frissítve', + 'comment_archive_success' => 'Hozzászólás archiválva', + 'comment_unarchive_success' => 'Hozzászólás visszaállítva', + 'comment_view' => 'Hozzászólás megjelenítése', + 'comment_jump_to_thread' => 'Ugrás hozzászóláslánchoz', + 'comment_delete_confirm' => 'Biztosan törli a megjegyzést?', + 'comment_in_reply_to' => 'Válaszolva erre: :commentId', + 'comment_reference' => 'Hivatkozás', + 'comment_reference_outdated' => '(Elavult)', + 'comment_editor_explain' => 'Itt vannak az ezen az oldalon lévő hozzászólások. Hozzászólás hozzáadhatók és kezelhetők a mentett oldal megtekintésekor.', // Revision - 'revision_delete_confirm' => 'Biztosan törölhető ez a változat?', - 'revision_restore_confirm' => 'Biztosan visszaállítható ez a változat? A oldal jelenlegi tartalma le lesz cserélve.', - 'revision_cannot_delete_latest' => 'A legutolsó változat nem törölhető.', + 'revision_delete_confirm' => 'Biztosan törli ezt a változatot?', + 'revision_restore_confirm' => 'Biztosan visszaállítja ezt a változatot? A oldal jelenlegi tartalma le lesz cserélve.', + 'revision_cannot_delete_latest' => 'A legutóbbi változat nem törölhető.', // Copy view 'copy_consider' => 'Kérem, fontolja meg az alábbiakat, amikor tartalmat kíván másolni.', - 'copy_consider_permissions' => 'Az egyéni engedélybeállítások nem kerülnek másolásra.', + 'copy_consider_permissions' => 'Az egyedi engedély beállítások nem kerülnek másolásra.', 'copy_consider_owner' => 'Minden lemásolt tartalomnak Ön lesz a tulajdonosa.', - 'copy_consider_images' => 'Az oldalképfájlok nem duplikálódnak, és az eredeti képek megőrzik kapcsolatukat az eredetileg feltöltött oldallal.', - 'copy_consider_attachments' => 'Az oldal mellékletei nem kerülnek másolásra.', - 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', + 'copy_consider_images' => 'Az kép fájlok nem duplikálódnak, és az eredeti képek megőrzik kapcsolatukat az oldallal, ahova eredetileg fel lettek töltve.', + 'copy_consider_attachments' => 'Az oldal mellékletei nem lesznek lemásolva.', + 'copy_consider_access' => 'A hely, tulajdonos vagy jogosultságok megváltoztatása olyanoknak is hozzáférést adhat a tartalomhoz, akik korábban nem érhették el.', // Conversions 'convert_to_shelf' => 'Átalakítás polccá', - 'convert_to_shelf_contents_desc' => 'Ezt a könyvet új polccá alakíthatja, azonos tartalommal. A könyvben található fejezetek új könyvekké lesznek átalakítva. Ha ez a könyv tartalmaz olyan oldalakat, amelyek nem szerepelnek egy fejezetben, akkor a könyv átnevezzük és tartalmaz ilyen oldalakat, és ez a könyv az új polc részévé válik.', - 'convert_to_shelf_permissions_desc' => 'A könyvhöz beállított engedélyek át lesznek másolva az új polcra és az összes olyan új alárendelt könyvre, amelyek nem rendelkeznek saját engedélyekkel. Vegye figyelembe, hogy a polcokon lévő engedélyek nem kapcsolódnak automatikusan a tartalomhoz, ahogy a könyvek esetében.', + 'convert_to_shelf_contents_desc' => 'Ezt a könyvet új polccá alakíthatja, azonos tartalommal. A könyvben található fejezetek új könyvekké lesznek átalakítva. Ha ez a könyv tartalmaz olyan oldalakat, amelyek nem szerepelnek egy fejezetben, akkor a könyv át lesz nevezve, az oldalak hozzárendelve és ez a könyv az új polc részévé válik.', + 'convert_to_shelf_permissions_desc' => 'A könyvhöz beállított engedélyek át lesznek másolva az új polcra és az összes olyan új alárendelt könyvre, amelyek nem rendelkeznek saját engedélyekkel. Vegye figyelembe, hogy a polcokon lévő engedélyek nem vonatkoznak automatikusan az alárendelt tartalomra, ahogy a könyvek esetében.', 'convert_book' => 'Könyv átalakítása', - 'convert_book_confirm' => 'Biztosan konvertálni szeretné ezt a könyvet?', + 'convert_book_confirm' => 'Biztosan át szeretné alakítani ezt a könyvet?', 'convert_undo_warning' => 'Ezt nem lehet olyan könnyen visszavonni.', 'convert_to_book' => 'Átalakítás könyvvé', 'convert_to_book_desc' => 'Ezt a fejezetet új, azonos tartalmú könyvvé alakíthatja. Az ebben a fejezetben beállított engedélyek átmásolódnak az új könyvbe, de a szülőkönyvből származó örökölt engedélyek nem kerülnek másolásra, ami a hozzáférés-szabályozás megváltozásához vezethet.', 'convert_chapter' => 'Fejezet átalakítása', - 'convert_chapter_confirm' => 'Biztosan át szeretnéd alakítani ezt a fejezetet?', + 'convert_chapter_confirm' => 'Biztosan át szeretné alakítani ezt a fejezetet?', // References - 'references' => 'Értékelések', + 'references' => 'Hivatkozások', 'references_none' => 'Nincsenek nyomon követett hivatkozások erre az elemre.', - 'references_to_desc' => 'Az alábbiakban felsoroljuk az összes ismert tartalmat a rendszerben, amely erre az elemre hivatkozik.', + 'references_to_desc' => 'Alább fel van sorolva az összes ismert tartalom a rendszerben, amely erre az elemre hivatkozik.', // Watch Options 'watch' => 'Megfigyelés', @@ -459,19 +462,19 @@ 'watch_desc_ignore' => 'Figyelmen kívül hagyja az összes értesítést, beleértve a felhasználói szintű beállításokból származó értesítéseket is.', 'watch_title_new' => 'Új oldalak', 'watch_desc_new' => 'Értesítés, ha új oldal jön létre ezen az elemen belül.', - 'watch_title_updates' => 'Minden oldal frissítése', - 'watch_desc_updates' => 'Értesítés minden új oldalról és oldalváltozásról.', - 'watch_desc_updates_page' => 'Értesítsen minden oldalváltozásról.', - 'watch_title_comments' => 'Az oldal összes frissítése és megjegyzése', - 'watch_desc_comments' => 'Értesítés minden új oldalról, oldalváltozásról és új megjegyzésről.', - 'watch_desc_comments_page' => 'Értesítés az oldal változásairól és az új megjegyzésekről.', + 'watch_title_updates' => 'Minden oldal változás', + 'watch_desc_updates' => 'Értesítés minden új oldalról és oldal változásról.', + 'watch_desc_updates_page' => 'Értesítsen minden oldal változásról.', + 'watch_title_comments' => 'Minden oldal változás és hozzászólás', + 'watch_desc_comments' => 'Értesítés minden új oldalról, oldal változásról és új hozzászólásról.', + 'watch_desc_comments_page' => 'Értesítés az oldal változásairól és az új hozzászólásokról.', 'watch_change_default' => 'Az alapértelmezett értesítési beállítások módosítása', 'watch_detail_ignore' => 'Az értesítések figyelmen kívül hagyása', 'watch_detail_new' => 'Új oldalak figyelése', - 'watch_detail_updates' => 'Új oldalak és frissítések figyelése', - 'watch_detail_comments' => 'Új oldalak, frissítések és megjegyzések figyelése', - 'watch_detail_parent_book' => 'Megfigyelés szülőkönyvből', - 'watch_detail_parent_book_ignore' => 'Figyelmen kívül hagyás a szülőkönyvön keresztül', - 'watch_detail_parent_chapter' => 'Megfigyelés szülő fejezetből', - 'watch_detail_parent_chapter_ignore' => 'Figyelmen kívül hagyás a szülő fejezeten keresztül', + 'watch_detail_updates' => 'Új oldalak és változások figyelése', + 'watch_detail_comments' => 'Új oldalak, változások és hozzászólások figyelése', + 'watch_detail_parent_book' => 'Megfigyelés tartalmazó könyvön keresztül', + 'watch_detail_parent_book_ignore' => 'Figyelmen kívül hagyás tartalmazó könyvön keresztül', + 'watch_detail_parent_chapter' => 'Megfigyelés tartalmazó fejezetten keresztül', + 'watch_detail_parent_chapter_ignore' => 'Figyelmen kívül hagyás tartalmazó fejezetten keresztül', ]; diff --git a/lang/hu/errors.php b/lang/hu/errors.php index 4264af8ab67..c3b1a4d6d53 100644 --- a/lang/hu/errors.php +++ b/lang/hu/errors.php @@ -6,46 +6,46 @@ // Permissions 'permission' => 'Nincs jogosultság a kért oldal eléréséhez.', - 'permissionJson' => 'Nincs jogosultság a kért művelet végrehajtásához.', + 'permissionJson' => 'Nincs jogosultsága a kért művelet végrehajtásához.', // Auth 'error_user_exists_different_creds' => ':email címmel már létezik felhasználó, de más hitelesítő adatokkal.', 'auth_pre_register_theme_prevention' => 'A felhasználói fiók nem regisztrálható a megadott adatokkal', - 'email_already_confirmed' => 'Az email cím már meg van erősítve, meg lehet próbálni a bejelentkezést.', - 'email_confirmation_invalid' => 'A megerősítő vezérjel nem érvényes vagy használva volt. Meg kell próbálni újraregisztrálni.', - 'email_confirmation_expired' => 'A megerősítő vezérjel lejárt. Egy új megerősítő email lett elküldve.', + 'email_already_confirmed' => 'Az email cím már meg van erősítve. Próbáljon meg bejelentkezni!', + 'email_confirmation_invalid' => 'Ez a megerősítő kulcs nem érvényes vagy használva már volt. Próbáljon meg újra regisztrálni!', + 'email_confirmation_expired' => 'Ez a megerősítő kulcs már lejárt. Egy új megerősítő email lett küldve.', 'email_confirmation_awaiting' => 'A használatban lévő fiók email címét meg kell erősíteni', 'ldap_fail_anonymous' => 'Nem sikerült az LDAP elérése névtelen csatlakozással', - 'ldap_fail_authed' => 'Az LDAP hozzáférés nem sikerült a megadott DN és jelszó beállításokkal', - 'ldap_extension_not_installed' => 'LDAP PHP kiterjesztés nincs telepítve', - 'ldap_cannot_connect' => 'Nem lehet kapcsolódni az LDAP kiszolgálóhoz, a kezdeti kapcsolatfelvétel nem sikerült', - 'saml_already_logged_in' => 'Már bejelentkezett', + 'ldap_fail_authed' => 'Nem sikerült az LDAP elérése a megadott DN és jelszó adatokkal', + 'ldap_extension_not_installed' => 'Az LDAP PHP kiterjesztés nincsen telepítve', + 'ldap_cannot_connect' => 'Nem lehet kapcsolódni az LDAP szerverhez, a kezdeti kapcsolatfelvétel nem sikerült', + 'saml_already_logged_in' => 'Már be van jelentkezve', 'saml_no_email_address' => 'Ehhez a felhasználóhoz nem található email cím a külső hitelesítő rendszer által átadott adatokban', - 'saml_invalid_response_id' => 'A külső hitelesítő rendszerből érkező kérést nem ismerte fel az alkalmazás által indított folyamat. Bejelentkezés után az előző oldalra történő visszalépés okozhatja ezt a hibát.', - 'saml_fail_authed' => 'Bejelentkezés :system használatával sikertelen, a rendszer nem biztosított sikeres hitelesítést', - 'oidc_already_logged_in' => 'Már bejelentkezett', + 'saml_invalid_response_id' => 'A külső hitelesítő rendszerből érkező kérést nem ismerte fel egy az alkalmazás által indított folyamat sem. Bejelentkezés után az előző oldalra történő visszalépés okozhatja ezt a hibát.', + 'saml_fail_authed' => 'A bejelentkezés a(z) :system használatával sikertelen volt, a rendszer nem biztosított sikeres hitelesítést', + 'oidc_already_logged_in' => 'Már be van jelentkezve', 'oidc_no_email_address' => 'Ehhez a felhasználóhoz nem található email cím a külső hitelesítő rendszer által átadott adatokban', - 'oidc_fail_authed' => 'Bejelentkezés :system használatával sikertelen, a rendszer nem biztosított sikeres hitelesítést', - 'social_no_action_defined' => 'Nincs művelet meghatározva', - 'social_login_bad_response' => "Hiba történt :socialAccount bejelentkezés közben:\n:error", - 'social_account_in_use' => ':socialAccount fiók már használatban van. :socialAccount opción keresztül érdemes megpróbálni a bejelentkezést.', - 'social_account_email_in_use' => ':email email cím már használatban van. Ha már van fiók létrehozva, :egy socialAccount fiókot hozzá lehet csatolni a profil beállításainál.', - 'social_account_existing' => ':socialAccount már hozzá van kapcsolva a fiókhoz.', - 'social_account_already_used_existing' => ':socialAccount fiókot már egy másik felhasználó használja.', - 'social_account_not_used' => ':socialAccount fiók nincs felhasználóhoz kapcsolva. A hozzákapcsolást a profil oldalon lehet elvégezni. ', - 'social_account_register_instructions' => ':socialAccount beállítása használatával is lehet fiókot regisztrálni, ha még nem volt fiók létrehozva.', - 'social_driver_not_found' => 'Közösségi meghajtó nem található', - 'social_driver_not_configured' => ':socialAccount közösségi beállítások nem megfelelőek.', - 'invite_token_expired' => 'Ez a meghívó hivatkozás lejárt. Helyette meg lehet próbálni új jelszót megadni a fiókhoz.', + 'oidc_fail_authed' => 'A bejelentkezés a(z) :system használatával sikertelen volt, a rendszer nem biztosított sikeres hitelesítést', + 'social_no_action_defined' => 'Nincs meghatározott művelet', + 'social_login_bad_response' => "Hiba történt a(z) :socialAccount bejelentkezés közben:\n:error", + 'social_account_in_use' => 'Ez a(z) :socialAccount fiók már használatban van. Próbáljon meg bejelentkezni a(z) :socialAccount opción keresztül!', + 'social_account_email_in_use' => 'A(z) :email email cím már használatban van. Ha már van fiókja, hozzá tudja kapcsolni a(z) :socialAccount fiókját a profil beállításainál.', + 'social_account_existing' => 'Ez a(z) :socialAccount már hozzá van kapcsolva a fiókjához.', + 'social_account_already_used_existing' => 'Ezt a(z) :socialAccount fiókot egy másik felhasználó már használja.', + 'social_account_not_used' => 'Ez a(z) :socialAccount fiók nincs egy felhasználóhoz sem kapcsolva. Kapcsolja hozzá a profil beállításoknál. ', + 'social_account_register_instructions' => 'Ha még nincsen fiókja, regisztrálhat egyet a(z) :socialAccount opció használatával.', + 'social_driver_not_found' => 'Nem található közösségi média illesztőprogram', + 'social_driver_not_configured' => 'A(z) :socialAccount fiók összekapcsolás beállításai nem megfelelőek.', + 'invite_token_expired' => 'Ez a meghívó már lejárt. Helyette megpróbálhatja a fiók jelszavát visszaállítani.', 'login_user_not_found' => 'A művelethez nem található felhasználó.', // System - 'path_not_writable' => ':filePath elérési út nem tölthető fel. Ellenőrizni kell, hogy az útvonal a kiszolgáló számára írható.', - 'cannot_get_image_from_url' => 'Nem lehet lekérni a képet innen: :url', - 'cannot_create_thumbs' => 'A kiszolgáló nem tud létrehozni bélyegképeket. Ellenőrizni kell, hogy telepítve van-a a GD PHP kiterjesztés.', - 'server_upload_limit' => 'A kiszolgáló nem engedélyez ilyen méretű feltöltéseket. Kisebb fájlmérettel kell próbálkozni.', - 'server_post_limit' => 'A szerver nem tudja fogadni a megadott adatmennyiséget. Próbálkozz újra kevesebb adattal vagy egy kisebb fájllal.', - 'uploaded' => 'A kiszolgáló nem engedélyez ilyen méretű feltöltéseket. Kisebb fájlmérettel kell próbálkozni.', + 'path_not_writable' => 'Nem sikerült feltölteni a :filePath elérési útra. Ellenőrizze, hogy az útvonal írható a szerveren!', + 'cannot_get_image_from_url' => 'Nem lehet lekérni a képet a(z) :url címről', + 'cannot_create_thumbs' => 'A szerver nem tud létrehozni bélyegképeket. Ellenőrizze, hogy telepítve van a GD PHP kiterjesztés!', + 'server_upload_limit' => 'A szerver nem engedélyez ekkora méretű feltöltéseket. Kérjük próbálkozzon kisebb fájl mérettel.', + 'server_post_limit' => 'A szerver nem tudja fogadni a megadott adatmennyiséget. Próbálkozzon újra kevesebb adattal vagy egy kisebb fájllal!', + 'uploaded' => 'A szerver nem engedélyez ekkora méretű feltöltéseket. Kérjük próbálkozzon kisebb fájl mérettel.', // Drawing & Images 'image_upload_error' => 'Hiba történt a kép feltöltése közben', @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Nem lehet törölni az egyetlen adminisztrátort', 'users_cannot_delete_guest' => 'A vendég felhasználót nem lehet törölni', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Nem lehetett létrehozni a felhasználót, mivel a meghívó levelet nem sikerült elküldeni', // Roles 'role_cannot_be_edited' => 'Ezt a szerepkört nem lehet szerkeszteni', @@ -87,16 +87,16 @@ 'role_cannot_remove_only_admin' => 'Ez a felhasználó az egyetlen, az adminisztrátor szerepkörhöz rendelt felhasználó. Eltávolítása előtt az adminisztrátor szerepkört át kell ruházni egy másik felhasználóra.', // Comments - 'comment_list' => 'Hiba történt a megjegyzések lekérése közben.', - 'cannot_add_comment_to_draft' => 'Vázlathoz nem lehet megjegyzéseket fűzni.', - 'comment_add' => 'Hiba történt a megjegyzés hozzáadása / frissítése közben.', - 'comment_delete' => 'Hiba történt a megjegyzés törlése közben.', - 'empty_comment' => 'Üres megjegyzést nem lehet hozzáadni.', + 'comment_list' => 'Hiba történt a hozzászólások lekérése közben.', + 'cannot_add_comment_to_draft' => 'Vázlathoz nem lehet hozzászólni.', + 'comment_add' => 'Hiba történt a hozzászólás hozzáadása/frissítése közben.', + 'comment_delete' => 'Hiba történt a hozzászólás törlése közben.', + 'empty_comment' => 'Üres hozzászólást nem lehet hozzáadni.', // Error pages - '404_page_not_found' => 'Oldal nem található', + '404_page_not_found' => 'Az oldal nem található', 'sorry_page_not_found' => 'Sajnáljuk, a keresett oldal nem található.', - 'sorry_page_not_found_permission_warning' => 'Ha arra számított, hogy ez az oldal létezik, előfordulhat, hogy nincs engedélye a megtekintésére.', + 'sorry_page_not_found_permission_warning' => 'Ha arra számított, hogy ez az oldal létezik, előfordulhat, hogy nincs jogosultsága a megtekintésére.', 'image_not_found' => 'A kép nem található', 'image_not_found_subtitle' => 'Sajnáljuk, a keresett kép nem található.', 'image_not_found_details' => 'Ha arra számított, hogy ez a kép létezik, akkor előfordulhat, hogy törölték.', @@ -106,26 +106,26 @@ 'back_soon' => 'Hamarosan újra elérhető lesz.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'Nem sikerült a ZIP fájlt olvasni.', + 'import_zip_cant_decode_data' => 'Nem sikerült megtalálni és dekódolni a data.json tartalmát a ZIP fájlban.', + 'import_zip_no_data' => 'A ZIP fájlban nincsen könyv, fejezet vagy oldal tartalom.', + 'import_zip_data_too_large' => 'A ZIP fájlban lévő data.json tartalma meghaladja a beállított feltöltési méret limitet.', + 'import_validation_failed' => 'A ZIP fájl importálásának ellenőrzése sikertelen volt a következő hibák miatt:', + 'import_zip_failed_notification' => 'Nem sikerült a ZIP fájlt importálni.', + 'import_perms_books' => 'Nem rendelkezik a könyvek létrehozásához szükséges jogosultságokkal.', + 'import_perms_chapters' => 'Nem rendelkezik a fejezetek létrehozásához szükséges jogosultságokkal.', + 'import_perms_pages' => 'Nem rendelkezik az oldalak létrehozásához szükséges jogosultságokkal.', + 'import_perms_images' => 'Nem rendelkezik a képek létrehozásához szükséges jogosultságokkal.', + 'import_perms_attachments' => 'Nem rendelkezik a csatolmányok létrehozásához szükséges jogosultságokkal.', // API errors - 'api_no_authorization_found' => 'A kérésben nem található hitelesítési vezérjel', - 'api_bad_authorization_format' => 'A kérésben hitelesítési vezérjel található de a formátuma érvénytelennek tűnik', - 'api_user_token_not_found' => 'A megadott hitelesítési vezérjelhez nem található egyező API vezérjel', - 'api_incorrect_token_secret' => 'Az API tokenhez használt secret helytelen', - 'api_user_no_api_permission' => 'A használt API vezérjel tulajdonosának nincs jogosultsága API hívások végrehajtásához', - 'api_user_token_expired' => 'A használt hitelesítési vezérjel lejárt', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_no_authorization_found' => 'A kérésben nem található hitelesítési kulcs', + 'api_bad_authorization_format' => 'A kérésben hitelesítési kulcs található de a formátuma érvénytelennek tűnik', + 'api_user_token_not_found' => 'A megadott hitelesítési kulcshoz nem található egyező API kulcs', + 'api_incorrect_token_secret' => 'Az API kulcshoz megadott jelkulcs helytelen', + 'api_user_no_api_permission' => 'A használt API kulcs tulajdonosának nincs jogosultsága API hívások végrehajtásához', + 'api_user_token_expired' => 'A használt hitelesítési kulcs lejárt', + 'api_cookie_auth_only_get' => 'Kizárólag GET kérések engedélyezettek az API-n keresztül süti alapú authentikáció használatkor', // Settings & Maintenance 'maintenance_test_email_failure' => 'Hiba történt egy teszt email küldésekor:', diff --git a/lang/hu/notifications.php b/lang/hu/notifications.php index d8a29688a13..e7073a4967b 100644 --- a/lang/hu/notifications.php +++ b/lang/hu/notifications.php @@ -4,26 +4,26 @@ */ return [ - 'new_comment_subject' => 'Új megjegyzés ezen az oldalon: :pageName', - 'new_comment_intro' => 'Egy felhasználó hozzászólt egy oldalon itt: :appName:', + 'new_comment_subject' => 'Új megjegyzés a(z) :pageName oldalon', + 'new_comment_intro' => 'Egy felhasználó hozzászólt egy oldalon a(z) :appName: alkalmazásban', 'new_page_subject' => 'Új oldal: :pageName', - 'new_page_intro' => 'Az új oldal létrehozása sikeres volt itt: :appName:', + 'new_page_intro' => 'Egy új oldal lett létrehozva a(z) :appName: alkalmazásban', 'updated_page_subject' => 'Frissített oldal: :pageName', 'updated_page_intro' => 'Az oldal frissítése sikeres volt itt: :appName:', - 'updated_page_debounce' => 'Az értesítések tömegének elkerülése érdekében egy ideig nem kap értesítést az oldal további szerkesztéseiről ugyanaz a szerkesztő.', - 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', - 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', + 'updated_page_debounce' => 'Az értesítések tömegének elkerülése érdekében egy ideig nem kap értesítést az oldal további szerkesztéseiről ugyanattól a szerkesztőtől.', + 'comment_mention_subject' => 'Ön meg lett említve a(z) :pageName oldalon', + 'comment_mention_intro' => 'Ön meg lett említve egy hozzászólásban a(z) :appName: alkalmazásban', 'detail_page_name' => 'Oldal neve:', 'detail_page_path' => 'Oldal helye:', 'detail_commenter' => 'Hozzászóló:', - 'detail_comment' => 'Megjegyzés:', + 'detail_comment' => 'Hozzászólás:', 'detail_created_by' => 'Készítette:', 'detail_updated_by' => 'Frissítette:', 'action_view_comment' => 'Hozzászólás megtekintése', 'action_view_page' => 'Oldal megtekintése', - 'footer_reason' => 'Ezt az értesítést azért küldtük, mert a :link lefedi ezt a tevékenységtípust ehhez az elemhez.', - 'footer_reason_link' => 'értesítési beállításait', + 'footer_reason' => 'Ezt az értesítést azért küldtük, mert a :link lefedi ezt a tevékenység típust ehhez az elemhez.', + 'footer_reason_link' => 'az Ön értesítési beállításai', ]; diff --git a/lang/hu/passwords.php b/lang/hu/passwords.php index 03a37684ec6..90b13c30f7f 100644 --- a/lang/hu/passwords.php +++ b/lang/hu/passwords.php @@ -7,9 +7,9 @@ return [ 'password' => 'A jelszónak legalább hat karakterből kell állnia, és egyeznie kell a megerősítéssel.', - 'user' => "Nem található felhasználó ezzel az e-mail címmel.", - 'token' => 'A jelszó visszaállító biztonsági kód nem érvényes ehhez az e-mail címhez.', - 'sent' => 'E-mailben elküldtük a jelszó visszaállító hivatkozást!', + 'user' => "Nem található felhasználó ezzel az email címmel.", + 'token' => 'A jelszó visszaállító kulcs nem érvényes ehhez az email címhez.', + 'sent' => 'Emailben elküldtük a jelszó visszaállító hivatkozást!', 'reset' => 'A jelszó visszaállítva!', ]; diff --git a/lang/hu/preferences.php b/lang/hu/preferences.php index fb3c335e8ae..735e1fab7e3 100644 --- a/lang/hu/preferences.php +++ b/lang/hu/preferences.php @@ -15,38 +15,38 @@ 'shortcuts_section_navigation' => 'Navigáció', 'shortcuts_section_actions' => 'Gyakori műveletek', 'shortcuts_save' => 'Billentyűparancsok mentése', - 'shortcuts_overlay_desc' => 'Megjegyzés: Amikor a gyorsbillentyűk engedélyezve vannak, egy segítő átfedés érhető el azzal, hogy a "?" billentyűt megnyomva kiemeli az aktuálisan látható képernyőn elérhető gyorsbillentyűket a műveletekhez.', - 'shortcuts_update_success' => 'A gyorsbillentyű-beállítások frissítve lettek!', - 'shortcuts_overview_desc' => 'A rendszerfelhasználói felületen történő navigálásához használható billentyűparancsok kezelése.', + 'shortcuts_overlay_desc' => 'Megjegyzés: Amikor a gyorsbillentyűk engedélyezve vannak, a súgó ablak elérhető azzal, hogy a "?" billentyűt megnyomva kiemeli az aktuálisan látható képernyőn elérhető gyorsbillentyűket a műveletekhez.', + 'shortcuts_update_success' => 'A gyorsbillentyű beállítások frissítve lettek!', + 'shortcuts_overview_desc' => 'A felhasználói felületen történő navigálásához használható billentyűparancsok kezelése.', 'notifications' => 'Értesítési beállítások', - 'notifications_desc' => 'Állítsd be az e-mail értesítéseket, amelyeket akkor kapsz, ha bizonyos tevékenység történik a rendszeren belül.', - 'notifications_opt_own_page_changes' => 'Értesítsen változásokról az általam tulajdonolt oldalakon', - 'notifications_opt_own_page_comments' => 'Értesítés a hozzászólásokról az általam tulajdonolt oldalakon', - 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', + 'notifications_desc' => 'A rendszerben folytatott tevékenységekről kapott értesítések beállítása.', + 'notifications_opt_own_page_changes' => 'Értesítsen változásokról az általam birtokolt oldalakon', + 'notifications_opt_own_page_comments' => 'Értesítés a hozzászólásokról az általam birtokolt oldalakon', + 'notifications_opt_comment_mentions' => 'Értesítsen, amikor megemlítenek egy hozzászólásban', 'notifications_opt_comment_replies' => 'Értesítsen válaszokról a hozzászólásaimra', 'notifications_save' => 'Beállítások mentése', 'notifications_update_success' => 'Az értesítési beállítások frissítve lettek!', 'notifications_watched' => 'Megfigyelt és figyelmen kívül hagyott elemek', 'notifications_watched_desc' => 'Az alábbi elemekre egyedi figyelési beállítások vannak alkalmazva. A beállítások frissítéséhez tekintsd meg az elemet, majd keresd a figyelési lehetőségeket az oldalsávban.', - 'auth' => 'Hozzáférés és Biztonság', + 'auth' => 'Hozzáférés és biztonság', 'auth_change_password' => 'Jelszó módosítása', - 'auth_change_password_desc' => 'Változtasd meg az alkalmazásba történő bejelentkezéshez használt jelszavadat. Ennek legalább 8 karakter hosszúnak kell lennie.', + 'auth_change_password_desc' => 'A bejelentkezéshez használt jelszó módosítása. Legalább 8 karakter hosszúnak kell lennie.', 'auth_change_password_success' => 'A jelszó frissítve lett!', 'profile' => 'Felhasználó részletei', 'profile_desc' => 'A kommunikációhoz és a rendszer személyre szabásához használt adatokon kívül kezelheti fiókja adatait, amelyek más felhasználók számára jelennek meg.', 'profile_view_public' => 'Nyilvános profil megtekintése', - 'profile_name_desc' => 'Állítsd be a megjelenített nevedet, amely látható lesz a rendszer többi felhasználója számára az általad végzett tevékenység és a saját tartalom révén.', - 'profile_email_desc' => 'Ezt az e-mail címet értesítésekre fogjuk használni, valamint az érvényben lévő beállítások függvényében hitelesítéshez is.', - 'profile_email_no_permission' => 'Sajnos nincs jogosultságod az e-mail cím megváltoztatására. Ha szeretnéd ezt megváltoztatni, kérj meg egy adminisztrátort, hogy ezt megtegye helyetted.', - 'profile_avatar_desc' => 'Válassz egy képet, amelyet a rendszerben használnál a neved mellett. A kép lehetőleg négyzet alakú és körülbelül 256px szélességű és magasságú legyen.', + 'profile_name_desc' => 'Állítsa be a megjelenített nevét amely látható lesz a rendszer többi felhasználója számára az Ön által végzett tevékenység és a saját tartalom révén.', + 'profile_email_desc' => 'Ezt az email címet értesítésekre fogjuk használni, valamint az érvényben lévő beállítások függvényében hitelesítéshez is.', + 'profile_email_no_permission' => 'Sajnos nincs jogosultsága az email cím megváltoztatására. Ha szeretné ezt megváltoztatni, kérjen meg egy adminisztrátort, hogy ezt megtegye helyette.', + 'profile_avatar_desc' => 'Válasszon egy képet, amelyet a rendszerben használni szeretne a neve mellett. A kép lehetőleg négyzet alakú és körülbelül 256 pixel szélességű és magasságú legyen.', 'profile_admin_options' => 'Adminisztrátori beállítások', - 'profile_admin_options_desc' => 'További adminisztrátori szintű lehetőségek, például a szerepkörök hozzárendelésének kezelése, megtalálhatóak a felhasználói fiókod beállításai között az "Beállítások > Felhasználók" területen az alkalmazásban.', + 'profile_admin_options_desc' => 'További adminisztrátori szintű lehetőségek, például a szerepkörök hozzárendelésének kezelése, megtalálhatók a felhasználói fiókod beállításai között az "Beállítások > Felhasználók" területen az alkalmazásban.', 'delete_account' => 'Felhasználói fiók törlése', - 'delete_my_account' => 'Törlöm a felhasználói fiókomat', - 'delete_my_account_desc' => 'Ez véglegesen törölni fogja a felhasználói fiókodat a rendszerből. Nem lesz lehetőséged visszaállítani ezt a fiókot, vagy visszavonni ezt a műveletet. A létrehozott tartalmak, például az oldalak és feltöltött képek megmaradnak.', - 'delete_my_account_warning' => 'Biztosan törölni szeretnéd a fiókodat?', + 'delete_my_account' => 'Saját felhasználói fiók törlése', + 'delete_my_account_desc' => 'Ez véglegesen törölni fogja a felhasználói fiókját a rendszerből. Nem lesz lehetősége visszaállítani ezt a fiókot, vagy visszavonni ezt a műveletet. A létrehozott tartalmak, például az oldalak és feltöltött képek megmaradnak.', + 'delete_my_account_warning' => 'Biztosan törölni szeretné a fiókját?', ]; diff --git a/lang/hu/settings.php b/lang/hu/settings.php index aaccbd35f4c..6dd7de33b54 100644 --- a/lang/hu/settings.php +++ b/lang/hu/settings.php @@ -14,46 +14,46 @@ // App Settings 'app_customization' => 'Személyre szabás', - 'app_features_security' => 'Jellemzők és biztonság', + 'app_features_security' => 'Funkciók és biztonság', 'app_name' => 'Alkalmazás neve', 'app_name_desc' => 'Ez a név meg fog jelenni a fejlécben és minden a rendszer által küldött emailben.', 'app_name_header' => 'Név mutatása a fejlécben', 'app_public_access' => 'Nyilvános hozzáférés', - 'app_public_access_desc' => 'Ha engedélyezett, a nem bejelentkezett felhasználók is hozzá tudnak férni a BookStack példány tartalmaihoz.', + 'app_public_access_desc' => 'Az opció engedélyezése lehetővé teszi látogatóknak, hogy bejelentkezés nélkül hozzáférjenek az alkalmazásban tárolt bizonyos tartalmakhoz.', 'app_public_access_desc_guest' => 'A nyilvános látogatók hozzáférése a "Guest" felhasználón keresztül irányítható.', 'app_public_access_toggle' => 'Nyilvános hozzáférés engedélyezése', - 'app_public_viewing' => 'Nyilvános megtekintés engedélyezve?', + 'app_public_viewing' => 'Engedélyezi a nyilvános elérést?', 'app_secure_images' => 'Magasabb biztonságú képfeltöltés', 'app_secure_images_toggle' => 'Magasabb biztonságú képfeltöltés engedélyezése', - 'app_secure_images_desc' => 'Teljesítmény optimalizálási okokból minden kép nyilvános. Ez a beállítás egy véletlenszerű, nehezen kitalálható karakterláncot illeszt a képek útvonalának elejére. Meg kell győződni róla, hogy a könnyű hozzáférés megakadályozása érdekében a könyvtár indexek nincsenek engedélyezve.', + 'app_secure_images_desc' => 'Teljesítmény optimalizálási okokból minden kép nyilvános. Ez a beállítás egy véletlenszerű, nehezen kitalálható karakterláncot illeszt a kép URL-ek elejére. Győződjön meg róla, hogy a könyvtárak indexelése nincsen engedélyezve!', 'app_default_editor' => 'Alapértelmezett oldal szerkesztő', - 'app_default_editor_desc' => 'Válassza ki, hogy alapértelmezés szerint melyik szerkesztőt szeretné használni az új oldalak szerkesztésekor. Ezt felülírhatja oldalszintű szinten, amennyiben az engedélyek lehetővé teszik.', + 'app_default_editor_desc' => 'Válassza ki, hogy alapértelmezés szerint melyik szerkesztőt szeretné használni az új oldalak szerkesztésekor. Ezt felülírhatja az egyes oldalak szintjén, amennyiben az engedélyek lehetővé teszik.', 'app_custom_html' => 'Egyéni HTML fejléc tartalom', 'app_custom_html_desc' => 'Az itt hozzáadott bármilyen tartalom be lesz illesztve minden oldal szekciójának aljára. Ez hasznos a stílusok felülírásához van analitikai kódok hozzáadásához.', - 'app_custom_html_disabled_notice' => 'Az egyéni HTML fejléc tartalom le van tiltva ezen a beállítási oldalon, hogy az esetleg hibásan megadott módosításokat vissza lehessen állítani.', + 'app_custom_html_disabled_notice' => 'Az egyéni HTML fejléc tartalom le van tiltva ezen a beállítási oldalon, hogy az esetleg hibásan megadott módosításokat vissza lehessen vonni.', 'app_logo' => 'Alkalmazás logó', - 'app_logo_desc' => 'Ez az alkalmazás fejléc sávjában van használva többek között. Ennek a képnek 86 képpont magasnak kell lennie. A nagy képek át lesznek méretezve.', + 'app_logo_desc' => 'Ez többek között az alkalmazás fejléc sávjában van használva. A képnek 86 pixel magasnak kell lennie. A nagy képek átméretezésre kerülnek.', 'app_icon' => 'Alkalmazás ikon', - 'app_icon_desc' => 'Ez az ikon a böngésző fülekhez és a gyorsikonokhoz használatos. Ez egy 256 képpont négyzet alakú PNG képnek kell lennie.', + 'app_icon_desc' => 'Ez az ikon a böngésző fülekhez és parancsikonokhoz használatos. Az ikonnak egy 256 pixeles, négyzetes PNG képnek kell lennie.', 'app_homepage' => 'Alkalmazás kezdőlapja', - 'app_homepage_desc' => 'A kezdőlapon az alapértelmezés szerinti nézet helyett megjelenő nézet kiválasztása. A kiválasztott oldalakon figyelmen kívül lesznek hagyva az oldal engedélyek.', + 'app_homepage_desc' => 'Válassza ki a kezdőlapon az alapértelmezett nézet helyett megjelenő oldalt!. A kiválasztott oldalon figyelmen kívül lesznek hagyva a hozzáférési jogosultságok.', 'app_homepage_select' => 'Egy oldal kiválasztása', 'app_footer_links' => 'Lábléc linkek', - 'app_footer_links_desc' => 'Adj hozzá linkeket a weboldal láblécéhez. Ezek a legtöbb oldalon megjelennek, beleértve azokat is, amelyekhez nincs szükség bejelentkezésre. Használhatsz egy "trans::" címkét a rendszer által definiált fordítások használatához. Például: A "trans::common.privacy_policy" használata a lefordított szöveget ("Adatvédelmi Irányelvek") adja vissza, és a "trans::common.terms_of_service" a "Szolgáltatási feltételek" lefordított szöveget adja eredményül.', + 'app_footer_links_desc' => 'Adjon hozzá linkeket a weboldal láblécéhez. Ezek a legtöbb oldalon megjelennek, beleértve azokat is, amelyekhez nincs szükség bejelentkezésre. Használhat egy "trans::" címkét a rendszer által definiált fordítások használatához. Például: A "trans::common.privacy_policy" használata a "Adatvédelmi Irányelvek" lefordított szöveget adja vissza, és a "trans::common.terms_of_service" a "Szolgáltatási feltételek" lefordított szöveget adja eredményül.', 'app_footer_links_label' => 'Link címke', 'app_footer_links_url' => 'Link URL', 'app_footer_links_add' => 'Lábléc hivatkozás hozzáadása', - 'app_disable_comments' => 'Megjegyzések letiltása', - 'app_disable_comments_toggle' => 'Megjegyzések letiltása', - 'app_disable_comments_desc' => 'Megjegyzések letiltása az alkalmazás összes oldalán.
    A már létező megjegyzések el lesznek rejtve.', + 'app_disable_comments' => 'Hozzászólások letiltása', + 'app_disable_comments_toggle' => 'Hozzászólások letiltása', + 'app_disable_comments_desc' => 'Hozzászólások letiltása az alkalmazás összes oldalán.
    A már létező hozzászólások el lesznek rejtve.', // Color settings 'color_scheme' => 'Alkalmazás színséma', - 'color_scheme_desc' => 'Állítsd be a színeket az alkalmazás felhasználói felületén. A színeket külön-külön lehet konfigurálni a sötét és a világos módokhoz, hogy a legjobban illeszkedjenek a témához, és biztosítsák az olvashatóságot.', - 'ui_colors_desc' => 'Állítsa be az alkalmazás elsődleges színét és alapértelmezett hivatkozási színét. Az elsődleges színt főként a fejléc szalaghirdetéséhez, a gombokhoz és a felület díszítéséhez használják. Az alapértelmezett hivatkozásszín a szöveges hivatkozásokhoz és műveletekhez használatos, mind az írott tartalomban, mind az alkalmazás felületén.', + 'color_scheme_desc' => 'Állítsa be az alkalmazás felhasználói felületének színeit. A színeket külön-külön lehet konfigurálni a sötét és a világos módokhoz, hogy a legjobban illeszkedjenek a témához, és biztosítsák az olvashatóságot.', + 'ui_colors_desc' => 'Állítsa be az alkalmazás elsődleges színét és az alapértelmezett hivatkozási színt. Az elsődleges színt főként a fejléc szalag, a gombok és egyéb díszítőelemek használják. Az alapértelmezett hivatkozás szín a szöveges hivatkozásokhoz és műveletekhez használatos, mind az írott tartalomban, mind az alkalmazás felhasználói felületén.', 'app_color' => 'Elsődleges szín', 'link_color' => 'Alapértelmezett link szín', - 'content_colors_desc' => 'Beállítja az elemek színét az oldalszervezési hierarchiában. Az olvashatóság szempontjából javasolt az alapértelmezés szerinti színhez hasonló fényerősséget választani.', + 'content_colors_desc' => 'Állítsa be az elemek színeit az oldal hierarchiában. Az olvashatóság érdekében javasolt az alapértelmezett színekhez hasonló világosságú színeket választani.', 'bookshelf_color' => 'Polc színe', 'book_color' => 'Könyv színe', 'chapter_color' => 'Fejezet színe', @@ -64,65 +64,65 @@ 'reg_settings' => 'Regisztráció', 'reg_enable' => 'Regisztráció engedélyezése', 'reg_enable_toggle' => 'Regisztráció engedélyezése', - 'reg_enable_desc' => 'Ha a regisztráció engedélyezett, akkor a felhasználó képes lesz bejelentkezni mint az alkalmazás egy felhasználója. Regisztráció után egy egyszerű, alapértelmezés szerinti felhasználói szerepkör lesz hozzárendelve.', + 'reg_enable_desc' => 'Ha a regisztráció engedélyezve van, akkor látogatók regisztrálhatják saját magukat az alkalmazásba. Az új felhasználókhoz az alapértelmezett felhasználói szerepkör kerül hozzárendelésre.', 'reg_default_role' => 'Regisztráció utáni alapértelmezett felhasználói szerepkör', - 'reg_enable_external_warning' => 'A fenti beállítási lehetőség nincs használatban, ha külső LDAP vagy SAML hitelesítés aktív. A nem létező tagok felhasználói fiókjai automatikusan létrejönnek ha a használatban lévő külső rendszeren sikeres a hitelesítés.', - 'reg_email_confirmation' => 'Email megerősítés', - 'reg_email_confirmation_toggle' => 'Email megerősítés szükséges', - 'reg_confirm_email_desc' => 'Ha a tartomány korlátozás be van állítva, akkor email megerősítés szükséges és ez a beállítás figyelmen kívül lesz hagyva.', - 'reg_confirm_restrict_domain' => 'Tartomány korlátozás', - 'reg_confirm_restrict_domain_desc' => 'Azoknak az email tartományoknak a vesszővel elválasztott listája, melyekre a regisztráció korlátozva lesz. A felhasználók egy emailt fognak kapni, hogy megerősítsék az email címüket mielőtt használni kezdhetnék az alkalmazást.
    Fontos tudni, hogy a felhasználók a sikeres regisztráció után megváltoztathatják az email címüket.', + 'reg_enable_external_warning' => 'A fenti beállítási lehetőség figyelmen kívül van hagyva, ha külső LDAP vagy SAML hitelesítés aktív. A nem létező tagok felhasználói fiókjai automatikusan létrejönnek ha a használatban lévő külső rendszeren sikeres a hitelesítés.', + 'reg_email_confirmation' => 'Email megerősítése', + 'reg_email_confirmation_toggle' => 'Email megerősítése szükséges', + 'reg_confirm_email_desc' => 'Ha a domain korlátozás be van állítva, akkor az email megerősítése kötelező, és ez a beállítás figyelmen kívül van hagyva.', + 'reg_confirm_restrict_domain' => 'Domain korlátozás', + 'reg_confirm_restrict_domain_desc' => 'Adja meg azon email domain-eket vesszővel tagolt listaként, amikre a regisztrációt korlátozni szeretné. A felhasználók egy emailt fognak kapni, hogy megerősítsék az email címüket mielőtt használni kezdhetnék az alkalmazást.
    Fontos tudni, hogy a felhasználók a sikeres regisztráció után megváltoztathatják az email címüket.', 'reg_confirm_restrict_domain_placeholder' => 'Nincs beállítva korlátozás', // Sorting Settings - 'sorting' => 'Lists & Sorting', - 'sorting_book_default' => 'Default Book Sort Rule', - 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', - 'sorting_rules' => 'Sort Rules', - 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', - 'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', - 'sort_rule_create' => 'Create Sort Rule', - 'sort_rule_edit' => 'Edit Sort Rule', - 'sort_rule_delete' => 'Delete Sort Rule', - 'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.', - 'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?', - 'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?', - 'sort_rule_details' => 'Sort Rule Details', - 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.', - 'sort_rule_operations' => 'Sort Operations', - 'sort_rule_operations_desc' => 'Configure the sort actions to be performed 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_available_operations' => 'Available Operations', - 'sort_rule_available_operations_empty' => 'No operations remaining', - 'sort_rule_configured_operations' => 'Configured Operations', - 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', - 'sort_rule_op_asc' => '(Asc)', - 'sort_rule_op_desc' => '(Desc)', - 'sort_rule_op_name' => 'Name - Alphabetical', - 'sort_rule_op_name_numeric' => 'Name - Numeric', - 'sort_rule_op_created_date' => 'Created Date', - 'sort_rule_op_updated_date' => 'Updated Date', - 'sort_rule_op_chapters_first' => 'Chapters First', - 'sort_rule_op_chapters_last' => 'Chapters Last', - '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' => 'Listák és rendezés', + 'sorting_book_default' => 'Alapértelmezett könyv rendezési szabály', + 'sorting_book_default_desc' => 'Válassza ki az új könyvekre alkalmazandó alapértelmezett rendezési szabályt. Ez nem fogja befolyásolni a már létező könyveket, és könyvenként felülbírálható.', + 'sorting_rules' => 'Rendezési szabályok', + 'sorting_rules_desc' => 'Ezek előre meghatározott rendezési szabályok, amelyek alkalmazhatók a rendszerben található tartalomra.', + 'sort_rule_assigned_to_x_books' => 'Hozzárendelve :count könyvhöz|Hozzárendelve :count könyvhöz', + 'sort_rule_create' => 'Rendezési szabály létrehozása', + 'sort_rule_edit' => 'Rendezési szabály szerkesztése', + 'sort_rule_delete' => 'Rendezési szabály törlése', + 'sort_rule_delete_desc' => 'Eltávolítja ezt a rendezési szabályt a rendszerből. A jelenleg ezt használó könyvek vissza lesznek állítva kézi rendezésre.', + 'sort_rule_delete_warn_books' => 'Ez a rendezési szabály :count könyvön van használva. Biztosan törli?', + 'sort_rule_delete_warn_default' => 'Ez a rendezési szabály jelenleg az alapértelmezett az új könyvekhez. Biztosan törli?', + 'sort_rule_details' => 'Rendezési szabály részletei', + 'sort_rule_details_desc' => 'Állítson be egy nevet a rendezési szabálynak, amely megjelenik majd a rendezési listákban.', + 'sort_rule_operations' => 'Rendezési műveletek', + 'sort_rule_operations_desc' => 'Állítsa be rendezési műveleteket az elemek átmozgatásával az elérhető műveletek listájából. A műveletek mindig fentről lefelé haladva kerülnek végrehajtásra. Mentéskor minden itt végzett változtatás az összes hozzárendelt könyvre érvényesítve lesz.', + 'sort_rule_available_operations' => 'Elérhető műveletek', + 'sort_rule_available_operations_empty' => 'Nincs több művelet', + 'sort_rule_configured_operations' => 'Beállított műveletek', + 'sort_rule_configured_operations_empty' => 'Húzzon/adjon hozzá műveleteket az "Elérhető műveletek" listáról', + 'sort_rule_op_asc' => '(Növekvő)', + 'sort_rule_op_desc' => '(Csökkenő)', + 'sort_rule_op_name' => 'Név - betűrend', + 'sort_rule_op_name_numeric' => 'Név - számsorrend', + 'sort_rule_op_created_date' => 'Létrehozás dátuma', + 'sort_rule_op_updated_date' => 'Frissítés dátuma', + 'sort_rule_op_chapters_first' => 'Fejezetek elől', + 'sort_rule_op_chapters_last' => 'Fejezetek hátul', + 'sorting_page_limits' => 'Oldalankénti megjelenítési limitek', + 'sorting_page_limits_desc' => 'Állítsa be oldalanként mennyi elem kerüljön megjelenítésre a rendszer külöböző listáiban. Általában a kisebb mennyiség jobb teljesítményhez vezet, nagyobb mennyiségnél nem kell annyi oldalon végigmenni. Ajánlott a 6 egyik többszörösét használni.', // Maintenance settings 'maint' => 'Karbantartás', - 'maint_image_cleanup' => 'Képek tisztítása', + 'maint_image_cleanup' => 'Képek kitakarítása', 'maint_image_cleanup_desc' => 'Végigolvassa az oldalakat és a tartalmak változatait, hogy leellenőrizze jelenleg mely képek és rajzok vannak használatban, és mely képek szerepelnek többször. A futtatása előtt feltétlen készíteni kell egy teljes adatbázis és lemezkép mentést.', 'maint_delete_images_only_in_revisions' => 'Törölje azokat a képeket is, amelyek csak a régi oldalverziókban léteznek', - 'maint_image_cleanup_run' => 'Tisztítás futtatása', - 'maint_image_cleanup_warning' => ':count potenciálisan nem használt képet találtam. Biztosan törölhetőek ezek a képek?', + 'maint_image_cleanup_run' => 'Takarítás futtatása', + 'maint_image_cleanup_warning' => ':count potenciálisan nem használt kép van. Biztosan törölni szeretné ezeket a képeket?', 'maint_image_cleanup_success' => ':count potenciálisan nem használt kép megtalálva és törölve!', 'maint_image_cleanup_nothing_found' => 'Nincsenek nem használt képek, semmi sem lett törölve!', - 'maint_send_test_email' => 'Teszt e-mail küldése', + 'maint_send_test_email' => 'Teszt email küldése', 'maint_send_test_email_desc' => 'Ez elküld egy teszt emailt a profilban megadott email címre.', - 'maint_send_test_email_run' => 'Teszt e-mail küldése', - 'maint_send_test_email_success' => 'Email elküldve :address címre', - 'maint_send_test_email_mail_subject' => 'Teszt e-mail', + 'maint_send_test_email_run' => 'Teszt email küldése', + 'maint_send_test_email_success' => 'Email elküldve a(z) :address címre', + 'maint_send_test_email_mail_subject' => 'Teszt email', 'maint_send_test_email_mail_greeting' => 'Az email kézbesítés működőképesnek tűnik!', 'maint_send_test_email_mail_text' => 'Gratulálunk! Mivel ez az email figyelmeztetés megérkezett az email beállítások megfelelőek.', - 'maint_recycle_bin_desc' => 'A törölt polcok, könyvek, fejezetek és oldalak a lomtárba kerülnek, így visszaállíthatók vagy véglegesen törölhetők. A rendszer konfigurációtól függően egy idő után a lomtárban lévő régebbi elemek automatikusan eltávolíthatók.', + 'maint_recycle_bin_desc' => 'A törölt polcok, könyvek, fejezetek és oldalak a lomtárba kerülnek, így visszaállíthatók vagy véglegesen törölhetők. A rendszer konfigurációtól függően egy idő után a lomtárban lévő régebbi elemek automatikusan eltávolításra kerülhetnek.', 'maint_recycle_bin_open' => 'Lomtár megnyitása', 'maint_regen_references' => 'Referenciák újragenerálása', 'maint_regen_references_desc' => 'Ez a művelet újraépíti az adatbázison belüli elemek közötti hivatkozási indexet. Ez általában automatikusan történik, de ez a művelet hasznos lehet régi vagy nem hivatalos módszerekkel hozzáadott tartalom indexeléséhez.', @@ -144,7 +144,7 @@ 'recycle_bin_destroy_confirm' => 'Ez a művelet véglegesen törli ezt az elemet a rendszerből az alább felsorolt összes alárendelt elemmel együtt, és nem fogja tudni visszaállítani ezt a tartalmat. Biztosan véglegesen törli ezt az elemet?', 'recycle_bin_destroy_list' => 'Megsemmisítendő elemek', 'recycle_bin_restore_list' => 'Visszaállítandó elemek', - 'recycle_bin_restore_confirm' => 'Ez a művelet visszaállítja a törölt elemet, beleértve az utódelemeket is, az eredeti helyükre. Ha az eredeti helyet azóta törölték, és most a lomtárban van, akkor a szülőelemet is vissza kell állítani.', + 'recycle_bin_restore_confirm' => 'Ez a művelet visszaállítja a törölt elemet, beleértve az alárendelt elemeket is, az eredeti helyükre. Ha az eredeti helyet azóta törölték, és most a lomtárban van, akkor a tartalmazó elemet is vissza kell állítani.', 'recycle_bin_restore_deleted_parent' => 'Ennek az elemnek a szülője is törölve lett. Ezek mindaddig törölve maradnak, amíg az adott szülőt is vissza nem állítják.', 'recycle_bin_restore_parent' => 'Szűlő visszaállítása', 'recycle_bin_destroy_notification' => 'Összesen :count elemet törölt a lomtárból.', @@ -160,7 +160,7 @@ 'audit_table_user' => 'Felhasználó', 'audit_table_event' => 'Esemény', 'audit_table_related' => 'Kapcsolódó elem vagy részlet', - 'audit_table_ip' => 'IP Cím', + 'audit_table_ip' => 'IP cím', 'audit_table_date' => 'Tevékenység időpontja', 'audit_date_from' => 'Kezdő dátum', 'audit_date_to' => 'Végdátum', @@ -168,46 +168,46 @@ // Role Settings 'roles' => 'Szerepkörök', 'role_user_roles' => 'Felhasználói szerepkörök', - 'roles_index_desc' => 'A szerepkörök a felhasználók csoportosítására és rendszerengedélyek biztosítására szolgálnak tagjaiknak. Ha egy felhasználó több szerepkör tagja, a megadott jogosultságok halmozódnak, és a felhasználó örökli az összes képességet.', + 'roles_index_desc' => 'A szerepkörök a felhasználók csoportosítására és rendszerengedélyek biztosítására szolgálnak. Ha egy felhasználó több szerepkör tagja, a megadott jogosultságok halmozódnak, és a felhasználó örökli az összes képességet.', 'roles_x_users_assigned' => ':count hozzárendelt felhasználó|:count hozzárendelt felhasználó', 'roles_x_permissions_provided' => ':count jogosultság|:count jogosultság', 'roles_assigned_users' => 'Hozzárendelt felhasználók', 'roles_permissions_provided' => 'Megadott jogosultságok', 'role_create' => 'Új szerepkör létrehozása', 'role_delete' => 'Szerepkör törlése', - 'role_delete_confirm' => 'Ez törölni fogja \':roleName\' szerepkört.', - 'role_delete_users_assigned' => 'Ehhez a szerepkörhöz :userCount felhasználó van hozzárendelve. Ha a felhasználókat át kell helyezni ebből a szerepkörből, akkor ki kell választani egy új szerepkört.', - 'role_delete_no_migration' => "Nincs felhasználó áthelyezés", - 'role_delete_sure' => 'Biztosan törölhető ez a szerepkör?', + 'role_delete_confirm' => 'Ez törölni fogja a(z) \':roleName\' szerepkört.', + 'role_delete_users_assigned' => 'Ehhez a szerepkörhöz :userCount felhasználó van hozzárendelve. Ha át szeretné helyezni a felhasználókat egy másik szerepkörbe, akkor válasszon egy új szerepkört az alábbi listából.', + 'role_delete_no_migration' => "Ne helyezze át a felhasználókat", + 'role_delete_sure' => 'Biztosan törli ezt a szerepkört?', 'role_edit' => 'Szerepkör szerkesztése', 'role_details' => 'Szerepkör részletei', 'role_name' => 'Szerepkör neve', 'role_desc' => 'Szerepkör rövid leírása', - 'role_mfa_enforced' => 'Kétlépcsős hitelesítés megkövetelése', + 'role_mfa_enforced' => 'Többlépcsős hitelesítés megkövetelése', 'role_external_auth_id' => 'Külső hitelesítés azonosítók', 'role_system' => 'Rendszer jogosultságok', 'role_manage_users' => 'Felhasználók kezelése', - 'role_manage_roles' => 'Szerepkörök és szerepkör engedélyek kezelése', + 'role_manage_roles' => 'Szerepkörök és jogosultságok kezelése', 'role_manage_entity_permissions' => 'Minden könyv, fejezet és oldalengedély kezelése', 'role_manage_own_entity_permissions' => 'Saját könyv, fejezet és oldalak engedélyeinek kezelése', 'role_manage_page_templates' => 'Oldalsablonok kezelése', 'role_access_api' => 'Hozzáférés a rendszer API-hoz', 'role_manage_settings' => 'Alkalmazás beállításainak kezelése', 'role_export_content' => 'Tartalom exportálása', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Tartalom importálása', 'role_editor_change' => 'Oldalszerkesztő módosítása', 'role_notifications' => 'Értesítések fogadása és kezelése', - 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', - 'role_asset' => 'Eszköz jogosultságok', + 'role_permission_note_users_and_roles' => 'Ezek a jogosultságok gyakorlatilag megtekintést és felhasználó/szerepkör keresést is lehetővé fognak tenni.', + 'role_asset' => 'Tartalom jogosultságok', 'roles_system_warning' => 'Ne feledje, hogy a fenti három engedély bármelyikéhez való hozzáférés lehetővé teszi a felhasználó számára, hogy módosítsa saját vagy a rendszerben mások jogosultságait. Csak megbízható felhasználókhoz rendeljen szerepeket ezekkel az engedélyekkel.', - 'role_asset_desc' => 'Ezek a jogosultságok vezérlik az alapértelmezés szerinti hozzáférést a rendszerben található eszközökhöz. A könyvek, fejezetek és oldalak jogosultságai felülírják ezeket a jogosultságokat.', - 'role_asset_admins' => 'Az adminisztrátorok automatikusan hozzáférést kapnak minden tartalomhoz, de ezek a beállítások megjeleníthetnek vagy elrejthetnek felhasználói felület beállításokat.', + 'role_asset_desc' => 'Ezek a jogosultságok vezérlik az alapértelmezés szerinti hozzáférést a rendszerben található tartalomhoz. A könyvek, fejezetek és oldalak jogosultságai felülírják ezeket a jogosultságokat.', + 'role_asset_admins' => 'Az adminisztrátorok automatikusan hozzáférést kapnak minden tartalomhoz, de ezek a beállítások megjeleníthetnek vagy elrejthetnek beállításokat a felhasználói felületen.', 'role_asset_image_view_note' => 'Ez a képkezelőn belüli láthatóságra vonatkozik. A feltöltött képfájlok tényleges elérése a rendszerkép tárolási beállításától függ.', - 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', + 'role_asset_users_note' => 'Ezek a jogosultságok gyakorlatilag megtekintést és felhasználó keresést is lehetővé fognak tenni.', '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_controlled_by_asset' => 'A feltöltött tartalom beállításaitól függ', + 'role_controlled_by_page_delete' => 'Az oldal törlési jogosultságoktól függ', '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', @@ -220,72 +220,75 @@ 'users_search' => 'Felhasználók keresése', 'users_latest_activity' => 'Legújabb tevékenység', 'users_details' => 'Felhasználó részletei', - 'users_details_desc' => 'Egy megjelenítendő név és email cím beállítása ennek a felhasználónak. Az email cím az alkalmazásba történő bejelentkezéshez lesz használva.', - 'users_details_desc_no_email' => 'Egy megjelenítendő név beállítása ennek a felhasználónak amiről mások felismerik.', + 'users_details_desc' => 'Megjelenítendő név és email cím beállítása ennek a felhasználónak. Az email cím az alkalmazásba történő bejelentkezéshez lesz használva.', + 'users_details_desc_no_email' => 'Megjelenítendő név beállítása ennek a felhasználónak, amiről mások felismerik.', 'users_role' => 'Felhasználói szerepkörök', - 'users_role_desc' => 'A felhasználó melyik szerepkörhöz lesz rendelve. Ha a felhasználó több szerepkörhöz van rendelve, akkor ezeknek a szerepköröknek a jogosultságai összeadódnak, és a a felhasználó a hozzárendelt szerepkörök minden képességét megkapja.', + 'users_role_desc' => 'Felhasználó szerepköreinek kiválasztása. Ha a felhasználó több szerepkörhöz van rendelve, akkor ezeknek a szerepköröknek a jogosultságai összeadódnak, és a a felhasználó a hozzárendelt szerepkörök minden képességét megkapja.', 'users_password' => 'Felhasználó jelszava', 'users_password_desc' => 'Az alkalmazásba bejelentkezéshez használható jelszó beállítása. Legalább 8 karakter hosszúnak kell lennie.', - 'users_send_invite_text' => 'Lehetséges egy meghívó emailt küldeni ennek a felhasználónak ami lehetővé teszi, hogy beállíthassa a saját jelszavát. Máskülönben a jelszót az erre jogosult felhasználónak kell beállítania.', + 'users_send_invite_text' => 'Dönthet úgy, hogy meghívó emailt küld ennek a felhasználónak, így az beállíthatja saját jelszavát. Ellenkező esetben Ön is beállíthat egy jelszót neki.', 'users_send_invite_option' => 'Felhasználó meghívó levél küldése', 'users_external_auth_id' => 'Külső hitelesítés azonosítója', - 'users_external_auth_id_desc' => 'Ha külső hitelesítési rendszer van használatban (például SAML2, OIDC vagy LDAP), ez az az azonosító, amely a BookStack felhasználót a hitelesítési rendszerfiókhoz kapcsolja. Ha az alapértelmezett e-mail alapú hitelesítést használja, figyelmen kívül hagyhatja ezt a mezőt.', + 'users_external_auth_id_desc' => 'Ha külső hitelesítési rendszer van használatban (például SAML2, OIDC vagy LDAP), ez az az azonosító, amely a BookStack felhasználót a hitelesítési rendszerfiókhoz kapcsolja. Ha az alapértelmezett email alapú hitelesítést használja, figyelmen kívül hagyhatja ezt a mezőt.', 'users_password_warning' => 'Csak akkor töltse ki az alábbi mezőt, ha módosítani szeretné ennek a felhasználónak a jelszavát.', - 'users_system_public' => 'Ez a felhasználó bármelyik, a példányt megtekintő felhasználót képviseli. Nem lehet vele bejelentkezni de automatikusan hozzá lesz rendelve.', + 'users_system_public' => 'Ez a felhasználó bármelyik, az alkalmazást megtekintő látogatót képviseli. Nem lehet vele bejelentkezni de automatikusan hozzá lesz rendelve.', 'users_delete' => 'Felhasználó törlése', 'users_delete_named' => ':userName felhasználó törlése', 'users_delete_warning' => '\':userName\' felhasználó teljesen törölve lesz a rendszerből.', - 'users_delete_confirm' => 'Biztosan törölhető ez a felhasználó?', + 'users_delete_confirm' => 'Biztosan törli ezt a felhasználót?', 'users_migrate_ownership' => 'Tulajdonjog átruházása', 'users_migrate_ownership_desc' => 'Válasszon itt egy felhasználót, ha azt szeretné, hogy egy másik felhasználó legyen a tulajdonosa az összes, jelenleg a felhasználó tulajdonában lévő elemnek.', 'users_none_selected' => 'Nincs felhasználó kiválasztva', 'users_edit' => 'Felhasználó szerkesztése', 'users_edit_profile' => 'Profil szerkesztése', - 'users_avatar' => 'Avatar használata', - 'users_avatar_desc' => 'A felhasználót ábrázoló kép kiválasztása. Kb. 256px méretű négyzetes képnek kell lennie.', + 'users_avatar' => 'Felhasználó profilképe', + 'users_avatar_desc' => 'Válasszon ki egy képet, ami ehhez a felhasználóhoz fog tartozni. Négyzetes, körülbelül 256 pixel széles képnek kell lennie.', 'users_preferred_language' => 'Előnyben részesített nyelv', 'users_preferred_language_desc' => 'Ez a beállítás megváltoztatja az alkalmazás felhasználói felületén használt nyelvet. Nincs hatása a felhasználók által létrehozott tartalomra.', - 'users_social_accounts' => 'Közösségi fiókok', + 'users_social_accounts' => 'Közösségi média fiókok', 'users_social_accounts_desc' => 'Tekintse meg a felhasználó csatlakoztatott közösségi fiókjainak állapotát. A közösségi fiókok az elsődleges hitelesítési rendszer mellett használhatók a rendszerhez való hozzáféréshez.', - 'users_social_accounts_info' => 'Itt lehet egyéb fiókokat hozzákapcsolni a gyorsabb és könnyebb bejelentkezés érdekében. Itt olyan fiókot lehet lecsatlakoztatni, melynek korábban nem volt engedélyezett hozzáférése. Visszavonja a hozzáférést a csatlakoztatott szociális fiók profilbeállításaiból.', + 'users_social_accounts_info' => 'Itt kapcsolhat össze külső fiókokat a gyorsabb és egyszerűbb bejelentkezés érdekében. Egy összekapcsolás megszüntetése itt, nem vonja vissza a már korábban engedélyezett hozzáférést. Vonja vissza a hozzáférést a külső fiók profil beállításaiban.', 'users_social_connect' => 'Fiók csatlakoztatása', 'users_social_disconnect' => 'Fiók lecsatlakoztatása', 'users_social_status_connected' => 'Csatlakozva', 'users_social_status_disconnected' => 'Lecsatlakozva', 'users_social_connected' => ':socialAccount fiók sikeresen csatlakoztatva a profilhoz.', 'users_social_disconnected' => ':socialAccount fiók sikeresen lecsatlakoztatva a profilról.', - 'users_api_tokens' => 'API vezérjelek', - 'users_api_tokens_desc' => 'A BookStack REST API-val történő hitelesítéshez használt hozzáférési token létrehozása és kezelése. Az API engedélyeit azon a felhasználón keresztül kezelik, akihez a token tartozik.', - 'users_api_tokens_none' => 'Ehhez a felhasználóhoz nincsenek létrehozva API vezérjelek', - 'users_api_tokens_create' => 'Vezérjel létrehozása', + 'users_api_tokens' => 'API kulcsok', + 'users_api_tokens_desc' => 'A BookStack REST API-val történő hitelesítéshez használt hozzáférési kulcs létrehozása és kezelése. Az API engedélyeit azon a felhasználón keresztül kezelik, akihez a token tartozik.', + 'users_api_tokens_none' => 'Ehhez a felhasználóhoz nincsenek létrehozva API kulcsok', + 'users_api_tokens_create' => 'Kulcs létrehozása', 'users_api_tokens_expires' => 'Lejárat', 'users_api_tokens_docs' => 'API dokumentáció', - 'users_mfa' => 'Többfaktoros hitelesítés', + 'users_mfa' => 'Többlépcsős hitelesítés', 'users_mfa_desc' => 'Állítsa be a többlépcsős azonosítást egy extra biztonsági rétegként a felhasználói fiókjához.', - 'users_mfa_x_methods' => ':count metódus konfigurálva|:count metódus konfigurálva', + 'users_mfa_x_methods' => ':count mód beállítva|:count mód beállítva', 'users_mfa_configure' => 'Módszer beállítása', + 'users_mfa_reset' => 'Többlépcsős azonosítási módok alaphelyzetbe állítása', + 'users_mfa_reset_desc' => 'Ez alaphelyzetbe fogja állítani a felhasználónak az összes beállított többlépcsős azonosítási módját. Ha valamelyik a szerepkörük előírja a többlépcsős azonosítást, akkor a következő bejelentkezésnél fel lesznek szólítva egy új beállítására.', + 'users_mfa_reset_confirm' => 'Biztos benne, hogy alaphelyzetbe szeretné állítani ennek a felhasználónak a többlépcsős azonosítási módjait?', // API Tokens - 'user_api_token_create' => 'API vezérjel létrehozása', + 'user_api_token_create' => 'API kulcs létrehozása', 'user_api_token_name' => 'Név', - 'user_api_token_name_desc' => 'Adjon a tokennek egy olvasható nevet, hogy a jövőben emlékeztessen a tervezett céljára.', + 'user_api_token_name_desc' => 'Adjon a kulcsnak egy olvasható nevet, hogy a jövőben emlékeztessen a tervezett céljára.', 'user_api_token_expiry' => 'Lejárati dátum', - 'user_api_token_expiry_desc' => 'Dátum megadása ameddig a vezérjel érvényes. Ez után a dátum után az ezzel a vezérjellel történő kérések nem fognak működni. Üresen hagyva a lejárati idő 100 évre lesz beállítva.', - 'user_api_token_create_secret_message' => 'Közvetlenül a token létrehozása után egy „Token ID” és „Token Secret” generálódik és jelenik meg. A Secret csak egyszer jelenik meg, ezért a folytatás előtt másolja át az értéket egy biztonságos helyre.', - 'user_api_token' => 'API vezérjel', - 'user_api_token_id' => 'Vezérjel azonosító', - 'user_api_token_id_desc' => 'Ez egy nem szerkeszthető, a rendszer által létrehozott azonosító ehhez a vezérjelhez amire API kérésekben lehet szükség.', - 'user_api_token_secret' => 'Vezérjel titkos kódja', - 'user_api_token_secret_desc' => 'Ez egy rendszer által generált "secret" ehhez a tokenhez, amelyet meg kell adni az API-kérésekben. Ez csak most jelenik meg, ezért másolja ezt az értéket egy biztonságos helyre.', - 'user_api_token_created' => 'Vezérjel létrehozva :timeAgo', - 'user_api_token_updated' => 'Vezérjel frissítve :timeAgo', - 'user_api_token_delete' => 'Vezérjel törlése', - 'user_api_token_delete_warning' => '\':tokenName\' nevű API vezérjel teljesen törölve lesz a rendszerből.', - 'user_api_token_delete_confirm' => 'Biztosan törölhető ez az API vezérjel?', + 'user_api_token_expiry_desc' => 'Dátum ameddig a kulcs érvényes. Ez után a dátum után az ezzel a kulccsal történő kérések nem fognak működni. Üresen hagyva a lejárati idő 100 évre lesz beállítva.', + 'user_api_token_create_secret_message' => 'Közvetlenül a kulcs létrehozása után egy „Kulcs azonosító” és „Titkos kód” generálódik és jelenik meg. A titkos kód csak egyszer jelenik meg, ezért a folytatás előtt másolja át az értéket egy biztonságos helyre.', + 'user_api_token' => 'API kulcs', + 'user_api_token_id' => 'Kulcs azonosító', + 'user_api_token_id_desc' => 'Ez egy nem szerkeszthető, a rendszer által létrehozott azonosító ehhez a kulcshoz, amire API kérésekben lehet szükség.', + 'user_api_token_secret' => 'Kulcs titkos kódja', + 'user_api_token_secret_desc' => 'Ez egy rendszer által generált "titok" ehhez a kulcshoz, amelyet meg kell adni az API-kérésekben. Ez csak most jelenik meg, ezért másolja ezt az értéket egy biztonságos helyre.', + 'user_api_token_created' => 'Kulcs létrehozva :timeAgo', + 'user_api_token_updated' => 'Kulcs frissítve :timeAgo', + 'user_api_token_delete' => 'Kulcs törlése', + 'user_api_token_delete_warning' => '\':tokenName\' nevű API kulcs teljesen törölve lesz a rendszerből.', + 'user_api_token_delete_confirm' => 'Biztosan törli ezt az API kulcsot?', // Webhooks 'webhooks' => 'Webhook-ok', - 'webhooks_index_desc' => 'A webhookok segítségével adatokat küldhetünk külső URL-ekre, amikor bizonyos műveletek és események történnek a rendszeren belül, ami lehetővé teszi az eseményalapú integrációt külső platformokkal, például üzenetküldő vagy értesítési rendszerekkel.', + 'webhooks_index_desc' => 'A webhookok segítségével adatokat küldhet külső URL-ekre, amikor bizonyos műveletek és események történnek a rendszeren belül. Ez lehetővé teszi az eseményalapú integrációt külső platformokkal, például üzenetküldő vagy értesítési rendszerekkel.', 'webhooks_x_trigger_events' => ':count kiváltó esemény|:count kiváltó esemény', 'webhooks_create' => 'Új webhook létrehozása', 'webhooks_none_created' => 'Még nincs létrehozva egy webhook sem.', @@ -303,19 +306,19 @@ 'webhooks_active' => 'Webhook aktív', 'webhook_events_table_header' => 'Események', 'webhooks_delete' => 'Webhook törlése', - 'webhooks_delete_warning' => 'Ezzel a \':webhookName\' nevű webhookot teljesen törli a rendszerből.', + 'webhooks_delete_warning' => 'Ezzel a(z) \':webhookName\' nevű webhookot teljesen törli a rendszerből.', 'webhooks_delete_confirm' => 'Biztosan törli ezt a webhookot?', 'webhooks_format_example' => 'Webhook formátum példa', - 'webhooks_format_example_desc' => 'A Webhook-adatok POST-kérésként kerülnek elküldésre a konfigurált végponthoz JSON-ként az alábbi formátumban. A "related_item" és az "url" tulajdonság nem kötelező, és az aktivált esemény típusától függ.', + 'webhooks_format_example_desc' => 'A Webhook-adatok POST-kérésként kerülnek elküldésre a beállított végponthoz JSON-ként az alábbi formátumban. A "related_item" és az "url" tulajdonság nem kötelező, és az aktivált esemény típusától függ.', 'webhooks_status' => 'Webhook állapota', 'webhooks_last_called' => 'Utolsó hívás:', 'webhooks_last_errored' => 'Utolsó hiba:', 'webhooks_last_error_message' => 'Utolsó hibaüzenet:', // Licensing - 'licenses' => 'Licenszek', + 'licenses' => 'Licencek', 'licenses_desc' => 'Ez az oldal a BookStack licencinformációit részletezi, a BookStackben használt projekteken és könyvtárakon kívül. Sok felsorolt projekt csak fejlesztési környezetben használható.', - 'licenses_bookstack' => 'BookStack Licensz', + 'licenses_bookstack' => 'BookStack licenc', 'licenses_php' => 'PHP könyvtár licencek', 'licenses_js' => 'JavaScript könyvtár licencek', 'licenses_other' => 'Egyéb licencek', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/hu/validation.php b/lang/hu/validation.php index b740ca0c60a..ec6f68fe215 100644 --- a/lang/hu/validation.php +++ b/lang/hu/validation.php @@ -8,56 +8,56 @@ return [ // Standard laravel validation lines - 'accepted' => ':attribute elfogadott kell legyen.', - 'active_url' => ':attribute nem érvényes webcím.', - 'after' => ':attribute dátumnak :date utáninak kell lennie.', - 'alpha' => ':attribute csak betűket tartalmazhat.', - 'alpha_dash' => ':attribute csak betűket, számokat és kötőjeleket tartalmazhat.', - 'alpha_num' => ':attribute csak betűket és számokat tartalmazhat.', - 'array' => ':attribute tömb kell legyen.', + 'accepted' => 'A(z) :attribute elfogadott kell legyen.', + 'active_url' => 'A(z) :attribute nem egy érvényes URL.', + 'after' => 'A(z) :attribute objektumnak egy :date utáni dátumnak kell lennie.', + 'alpha' => 'A(z) :attribute csak betűket tartalmazhat.', + 'alpha_dash' => 'A(z) :attribute csak betűket, számokat, kötőjeleket és alávonásokat tartalmazhat.', + 'alpha_num' => 'A(z) :attribute csak betűket és számokat tartalmazhat.', + 'array' => 'A(z) :attribute tömb kell legyen.', 'backup_codes' => 'A megadott kód érvénytelen, vagy már felhasználták.', - 'before' => ':attribute dátumnak :date előttinek kell lennie.', + 'before' => 'A(z) :attribute objektumnak egy :date előtti dátumnak kell lennie.', 'between' => [ - 'numeric' => ':attribute értékének :min és :max között kell lennie.', - 'file' => ':attribute értékének :min és :max kilobájt között kell lennie.', - 'string' => ':attribute hosszának :min és :max karakter között kell lennie.', - 'array' => ':attribute mennyiségének :min és :max elem között kell lennie.', + 'numeric' => 'A(z) :attribute értékének :min és :max között kell lennie.', + 'file' => 'A(z) :attribute értékének :min és :max kB között kell lennie.', + 'string' => 'A(z) :attribute hosszának :min és :max karakter között kell lennie.', + 'array' => 'A(z) :attribute tömbnek :min és :max közötti elemszámának kell lennie.', ], - 'boolean' => ':attribute mezőnek igaznak vagy hamisnak kell lennie.', - 'confirmed' => ':attribute megerősítés nem egyezik.', - 'date' => ':attribute nem érvényes dátum.', - 'date_format' => ':attribute nem egyezik :format formátummal.', - 'different' => ':attribute és :other értékének különböznie kell.', - 'digits' => ':attribute :digits számból kell álljon.', - 'digits_between' => ':attribute hosszának :min és :max számjegy között kell lennie.', - 'email' => ':attribute érvényes email cím kell legyen.', - 'ends_with' => ':attribute attribútumnak a következők egyikével kell végződnie: :values', - 'file' => 'A(z) :attribute érvényes fájlnak kell lennie.', - 'filled' => ':attribute mező kötelező.', + 'boolean' => 'A(z) :attribute mezőnek igaznak vagy hamisnak kell lennie.', + 'confirmed' => 'A(z) :attribute megerősítés nem egyezik.', + 'date' => 'A(z) :attribute nem egy érvényes dátum.', + 'date_format' => 'A(z) :attribute nem egyezik a(z) :format formátummal.', + 'different' => 'A(z) :attribute és :other értékének különböznie kell.', + 'digits' => 'A(z) :attribute :digits számjegyből kell álljon.', + 'digits_between' => 'A(z) :attribute hosszának :min és :max számjegy között kell lennie.', + 'email' => 'A(z) :attribute érvényes email cím kell legyen.', + 'ends_with' => 'A(z) :attribute értékének a következők egyikével kell végződnie: :values', + 'file' => 'A(z) :attribute érvényes fájl kell legyen.', + 'filled' => 'A(z) :attribute mező kötelező.', 'gt' => [ - 'numeric' => ':attribute nagyobb kell, hogy legyen, mint :value.', - 'file' => ':attribute nagyobb kell, hogy legyen, mint :value kilobájt.', - 'string' => ':attribute nagyobb kell legyen mint :value karakter.', - 'array' => ':attribute több, mint :value elemet kell, hogy tartalmazzon.', + 'numeric' => 'A(z) :attribute nagyobb kell, hogy legyen, mint :value.', + 'file' => 'A(z) :attribute nagyobb kell, hogy legyen, mint :value kB.', + 'string' => 'A(z) :attribute hosszabb kell legyen mint :value karakter.', + 'array' => 'A(z) :attribute több, mint :value elemet kell, hogy tartalmazzon.', ], 'gte' => [ - 'numeric' => ':attribute attribútumnak :value értéknél nagyobbnak vagy vele egyenlőnek kell lennie.', - 'file' => 'A(z) :attribute mérete nem lehet kevesebb, mint :value kilobájt.', - 'string' => 'A(z) :attribute nagyobbnak, vagy egyenlőnek kell lennie, mint a :value karakter.', - 'array' => 'A(z) :attribute rendelkezzen :value vagy több elemmel.', + 'numeric' => 'A(z) :attribute számnak :value értéknél nagyobbnak vagy vele egyenlőnek kell lennie.', + 'file' => 'A(z) :attribute mérete nem lehet kevesebb, mint :value kB.', + 'string' => 'A(z) :attribute szövegnek legalább :value karakter hosszúnak kell lennie.', + 'array' => 'A(z) :attribute tömbnek :value vagy több elemmel kell rendelkeznie.', ], 'exists' => 'A kiválasztott :attribute érvénytelen.', - 'image' => ':attribute kép kell legyen.', - 'image_extension' => 'A :attribute kép kiterjesztése érvényes és támogatott kell legyen.', + 'image' => 'A(z) :attribute kép kell legyen.', + 'image_extension' => 'A(z) :attribute kép kiterjesztése érvényes és támogatott kell legyen.', 'in' => 'A kiválasztott :attribute érvénytelen.', - 'integer' => ':attribute egész szám kell legyen.', - 'ip' => ':attribute érvényes IP cím kell legyen.', - 'ipv4' => 'A(z) :attribute érvényes IPv4 címnek kell lennie.', - 'ipv6' => 'A(z) :attribute érvényes IPv6 címnek kell lennie.', - 'json' => 'A(z) :attribute érvényes JSON stringnek kell lennie.', + 'integer' => 'A(z) :attribute egész szám kell legyen.', + 'ip' => 'A(z) :attribute érvényes IP cím kell legyen.', + 'ipv4' => 'A(z) :attribute érvényes IPv4 cím kell legyen.', + 'ipv6' => 'A(z) :attribute érvényes IPv6 cím kell legyen.', + 'json' => 'A(z) :attribute érvényes JSON szöveg kell legyen.', 'lt' => [ - 'numeric' => 'A(z) :attribute kisebb kell, hogy legyen, mint :value.', - 'file' => 'A(z) :attribute kevesebbnek kell lennie, mint :value kilobájt.', + 'numeric' => 'A(z) :attribute szám kisebb kell, hogy legyen, mint :value.', + 'file' => 'A(z) :attribute fájlnak kisebbnek kell lennie, mint :value kB.', 'string' => 'A(z) :attribute rövidebb kell, hogy legyen, mint :value karakter.', 'array' => 'A(z) :attribute kevesebb, mint :value elemet kell, hogy tartalmazzon.', ], @@ -69,47 +69,47 @@ ], 'max' => [ 'numeric' => ':attribute nem lehet nagyobb mint :max.', - 'file' => ':attribute nem lehet nagyobb mint :max kilobájt.', - 'string' => ':attribute nem lehet nagyobb mint :max karakter.', - 'array' => ':attribute mennyisége nem lehet több mint :max elem.', + 'file' => 'A(z) :attribute nem lehet nagyobb, mint :max kB.', + 'string' => 'A(z) :attribute nem lehet hosszabb, mint :max karakter.', + 'array' => 'A(z) :attribute nem tartalmazhat több, mint :max elemet.', ], - 'mimes' => 'A :attribute típusa csak :values lehet.', + 'mimes' => 'A(z) :attribute típusa csak :values lehet.', 'min' => [ - 'numeric' => ':attribute legalább :min kell legyen.', - 'file' => ':attribute legalább :min kilobájt kell legyen.', - 'string' => ':attribute legalább :min karakter kell legyen.', - 'array' => ':attribute legalább :min elem kell legyen.', + 'numeric' => 'A(z) :attribute legalább :min kell legyen.', + 'file' => 'A(z) :attribute legalább :min kB kell legyen.', + 'string' => 'A(z) :attribute legalább :min karakter kell legyen.', + 'array' => 'A(z) :attribute legalább :min elemet kell tartalmazzon.', ], 'not_in' => 'A kiválasztott :attribute érvénytelen.', - 'not_regex' => ':attribute formátuma érvénytelen.', - 'numeric' => ':attribute szám kell legyen.', - 'regex' => ':attribute formátuma érvénytelen.', - 'required' => ':attribute mező kötelező.', - 'required_if' => ':attribute mező kötelező ha :other értéke :value.', - 'required_with' => ':attribute mező kötelező ha :values be van állítva.', - 'required_with_all' => ':attribute mező kötelező ha van :value.', - 'required_without' => ':attribute mező kötelező ha :values nincs beállítva.', - 'required_without_all' => ':attribute mező kötelező ha egyik :values sincs beállítva.', - 'same' => ':attribute és :other értékének egyeznie kell.', + 'not_regex' => 'A(z) :attribute formátuma érvénytelen.', + 'numeric' => 'A(z) :attribute szám kell legyen.', + 'regex' => 'A(z) :attribute formátuma érvénytelen.', + 'required' => 'A(z) :attribute mező kötelező.', + 'required_if' => 'A(z) :attribute mező kötelező ha :other értéke :value.', + 'required_with' => 'A(z) :attribute mező kötelező ha :values be van állítva.', + 'required_with_all' => 'A(z) :attribute mező kötelező ha :values be van állítva.', + 'required_without' => 'A(z) :attribute mező kötelező ha :values nincs beállítva.', + 'required_without_all' => 'A(z) :attribute mező kötelező ha egyik :values sincs beállítva.', + 'same' => 'A(z) :attribute és :other értékének egyeznie kell.', 'safe_url' => 'Előfordulhat, hogy a megadott link nem biztonságos.', 'size' => [ - 'numeric' => ':attribute :size méretű kell legyen.', - 'file' => ':attribute :size kilobájt méretű kell legyen.', - 'string' => ':attribute :size karakter kell legyen.', - 'array' => ':attribute : size elemet kell tartalmazzon.', + 'numeric' => 'A(z) :attribute :size méretű kell legyen.', + 'file' => 'A(z) :attribute :size kB méretű kell legyen.', + 'string' => 'A(z) :attribute :size karakter kell legyen.', + 'array' => 'A(z) :attribute :size elemet kell tartalmazzon.', ], - 'string' => ':attribute karaktersorozatnak kell legyen.', - 'timezone' => ':attribute érvényes zóna kell legyen.', + 'string' => 'A(z) :attribute szöveg kell legyen.', + 'timezone' => 'A(z) :attribute érvényes időzóna kell legyen.', 'totp' => 'A megadott kód érvénytelen vagy lejárt.', - 'unique' => ':attribute már elkészült.', - 'url' => ':attribute formátuma érvénytelen.', - 'uploaded' => 'A fájlt nem lehet feltölteni. A kiszolgáló nem fogad el ilyen méretű fájlokat.', + 'unique' => 'A(z) :attribute már foglalt.', + 'url' => 'A(z) :attribute formátuma érvénytelen.', + 'uploaded' => 'A fájlt nem lehet feltölteni. A szerver nem fogad el ilyen méretű fájlokat.', - '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' => 'A(z) :attribute egy a ZIP fájlban található fájlra kell, hogy hivatkozzon', + 'zip_file_size' => 'A(z) :attribute fájl nem haladhatja meg a :size MB méretet.', + 'zip_file_mime' => 'A(z) :attribute egy :validTypes típusú fájlra kell, hogy hivatkozzon, a :foundType helyett.', + 'zip_model_expected' => 'Adat objektum helyett ":type" lett találva.', + 'zip_unique' => 'A(z) :attribute egyedi kell hogy legyen a ZIP fájlban az adott objektum típushoz.', // Custom validation lines 'custom' => [ diff --git a/lang/id/activities.php b/lang/id/activities.php index 6e1583a94e7..db32fcf644a 100644 --- a/lang/id/activities.php +++ b/lang/id/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Pengguna berhasil diperbarui', 'user_delete' => 'pengguna yang dihapus', 'user_delete_notification' => 'Pengguna berhasil dihapus', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'API token yang dibuat', diff --git a/lang/id/auth.php b/lang/id/auth.php index 6af60ca80b1..a5653ede685 100644 --- a/lang/id/auth.php +++ b/lang/id/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Kredensial tidak cocok dengan catatan kami.', 'throttle' => 'Terlalu banyak upaya masuk. Silahkan mencoba lagi dalam :seconds detik.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Daftar', diff --git a/lang/id/entities.php b/lang/id/entities.php index d978a3d19b1..a423ef39c88 100644 --- a/lang/id/entities.php +++ b/lang/id/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Halaman Tag', 'chapter_tags' => 'Bab Tag', 'book_tags' => 'Tag Buku', diff --git a/lang/id/settings.php b/lang/id/settings.php index fe3289ffa46..5d785314701 100644 --- a/lang/id/settings.php +++ b/lang/id/settings.php @@ -264,6 +264,9 @@ '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 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' => 'Buat Token API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/is/activities.php b/lang/is/activities.php index e70aa2567f0..7056e633c3d 100644 --- a/lang/is/activities.php +++ b/lang/is/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Tókst að uppfæra notanda', 'user_delete' => 'eyddur notandi', 'user_delete_notification' => 'Tókst að eyða notanda', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'API token búið til', diff --git a/lang/is/auth.php b/lang/is/auth.php index c5e7ce4f1d7..9b779aec7e7 100644 --- a/lang/is/auth.php +++ b/lang/is/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Þeesi auðkenning er ekki á skrá.', 'throttle' => 'Of margar tilraunir til innskráningar. Reyndu aftur eftir :seconds sekúndur.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Nýskrá', diff --git a/lang/is/entities.php b/lang/is/entities.php index 0d9a6fc1eb7..8735a18aea3 100644 --- a/lang/is/entities.php +++ b/lang/is/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Page Tags', 'chapter_tags' => 'Chapter Tags', 'book_tags' => 'Book Tags', diff --git a/lang/is/settings.php b/lang/is/settings.php index 877e0dfa8ce..cabe31917ef 100644 --- a/lang/is/settings.php +++ b/lang/is/settings.php @@ -264,6 +264,9 @@ '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 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' => 'Búa til API tóka', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/it/activities.php b/lang/it/activities.php index d682ccca6e0..e0dba540383 100644 --- a/lang/it/activities.php +++ b/lang/it/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Utente aggiornato con successo', 'user_delete' => 'ha eliminato un utente', 'user_delete_notification' => 'Utente rimosso con successo', + 'user_mfa_reset' => 'resetta MFA per l\'utente', + 'user_mfa_reset_notification' => 'Metodi di autenticazione multi-fattore reimpostati', // API Tokens 'api_token_create' => 'ha creato un token API', diff --git a/lang/it/auth.php b/lang/it/auth.php index 9191ecba794..3dd5e1ec62e 100644 --- a/lang/it/auth.php +++ b/lang/it/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Credenziali errate.', 'throttle' => 'Troppi tentativi di login. Riprova in :seconds secondi.', + 'mfa_throttle' => 'Troppi tentativi di verifica multi-fattore. Riprova tra :seconds secondi.', // Login & Register 'sign_up' => 'Registrati', diff --git a/lang/it/entities.php b/lang/it/entities.php index 1cce5fcd54f..89e1cd98831 100644 --- a/lang/it/entities.php +++ b/lang/it/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Attiva/disattiva barra laterale', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Tag pagina', 'chapter_tags' => 'Tag capitolo', 'book_tags' => 'Tag libro', diff --git a/lang/it/settings.php b/lang/it/settings.php index 9a272db33d9..3b8681840f2 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -207,7 +207,7 @@ '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_controlled_by_page_delete' => 'Controllato dai permessi di cancellazione della pagina', 'role_save' => 'Salva ruolo', 'role_users' => 'Utenti in questo ruolo', 'role_users_none' => 'Nessun utente assegnato a questo ruolo', @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Imposta l\'autenticazione multi-fattore come misura di sicurezza aggiuntiva per il tuo account.', 'users_mfa_x_methods' => ':count metodo configurato|:count metodi configurati', 'users_mfa_configure' => 'Configura metodi', + 'users_mfa_reset' => 'Reimposta Metodi Di Autenticazione Multi-Fattore', + 'users_mfa_reset_desc' => 'Questo ripristinerà e cancellerà tutti i metodi di autenticazione multi-fattore configurati per questo utente. Se l\'autenticazione multi-fattore è richiesta da uno qualsiasi dei loro ruoli, sarà richiesto loro di configurare nuovi metodi al loro prossimo accesso.', + 'users_mfa_reset_confirm' => 'Sei sicuro di voler resettare l\'autenticazione multi-fattore per questo utente?', // API Tokens 'user_api_token_create' => 'Crea token API', @@ -364,6 +367,7 @@ 'sk' => 'Sloveno', 'sl' => 'Sloveno', 'sv' => 'Svedese', + 'th' => 'ภาษาไทย', 'tr' => 'Turco', 'uk' => 'Ucraino', 'uz' => 'O‘zbekcha', diff --git a/lang/ja/activities.php b/lang/ja/activities.php index 6f55098bbd9..25ded1d49b7 100644 --- a/lang/ja/activities.php +++ b/lang/ja/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'ユーザーを更新しました', 'user_delete' => 'がユーザを削除', 'user_delete_notification' => 'ユーザーを削除しました', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'がAPIトークンを作成', diff --git a/lang/ja/auth.php b/lang/ja/auth.php index 077c31ac85c..b4ee5f4acf9 100644 --- a/lang/ja/auth.php +++ b/lang/ja/auth.php @@ -8,6 +8,7 @@ 'failed' => 'この資格情報は登録されていません。', 'throttle' => 'ログイン試行回数が制限を超えました。:seconds秒後に再試行してください。', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => '新規登録', diff --git a/lang/ja/entities.php b/lang/ja/entities.php index 57703997f3f..eaaddf5b3af 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -332,6 +332,9 @@ // Editor Sidebar 'toggle_sidebar' => 'サイドバーの切り替え', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'タグ', 'chapter_tags' => 'チャプターのタグ', 'book_tags' => 'ブックのタグ', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index a3cbf696a18..95f33f34004 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'アカウントのセキュリティを強化するために、多要素認証を設定してください。', 'users_mfa_x_methods' => ':count個の手段が設定されています|:count個の手段が設定されています', 'users_mfa_configure' => '手段を設定', + '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' => 'APIトークンの作成', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/ka/activities.php b/lang/ka/activities.php index 35730fd77a0..07304a56277 100644 --- a/lang/ka/activities.php +++ b/lang/ka/activities.php @@ -99,6 +99,8 @@ '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/ka/auth.php b/lang/ka/auth.php index 57f0cb5c632..47be4ea721e 100644 --- a/lang/ka/auth.php +++ b/lang/ka/auth.php @@ -8,6 +8,7 @@ 'failed' => 'These credentials do not match our records.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Sign up', diff --git a/lang/ka/entities.php b/lang/ka/entities.php index 5501d2bc229..58c00ec4b27 100644 --- a/lang/ka/entities.php +++ b/lang/ka/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Page Tags', 'chapter_tags' => 'Chapter Tags', 'book_tags' => 'Book Tags', diff --git a/lang/ka/settings.php b/lang/ka/settings.php index 3937c650f86..d03024a89d6 100644 --- a/lang/ka/settings.php +++ b/lang/ka/settings.php @@ -264,6 +264,9 @@ '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 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', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/ko/activities.php b/lang/ko/activities.php index 061b7fb1e5c..e011523face 100644 --- a/lang/ko/activities.php +++ b/lang/ko/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => '사용자가 업데이트되었습니다', 'user_delete' => '사용자 삭제', 'user_delete_notification' => '사용자가 삭제되었습니다', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => '생성된 API 토큰', diff --git a/lang/ko/auth.php b/lang/ko/auth.php index af9129f14e1..9e93c884684 100644 --- a/lang/ko/auth.php +++ b/lang/ko/auth.php @@ -8,6 +8,7 @@ 'failed' => '자격 증명이 기록과 일치하지 않습니다.', 'throttle' => '로그인 시도가 너무 많습니다. :seconds초 후에 다시 시도해주세요.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => '가입', diff --git a/lang/ko/entities.php b/lang/ko/entities.php index c54d6c7dc05..838aebd7a69 100644 --- a/lang/ko/entities.php +++ b/lang/ko/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => '사이드바 토글', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => '페이지 태그', 'chapter_tags' => '장 태그', 'book_tags' => '책 태그', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 9aabe5c2a81..90d501a7cb0 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => '추가 보안 계층으로 다중 인증을 설정합니다.', 'users_mfa_x_methods' => ':count 설정함|:count 설정함', 'users_mfa_configure' => '설정', + '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' => 'API 토큰 만들기', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/ku/activities.php b/lang/ku/activities.php index 4362fc02958..e9344a3d477 100644 --- a/lang/ku/activities.php +++ b/lang/ku/activities.php @@ -99,6 +99,8 @@ '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/ku/auth.php b/lang/ku/auth.php index 369e06f93ed..5597683ae84 100644 --- a/lang/ku/auth.php +++ b/lang/ku/auth.php @@ -8,6 +8,7 @@ 'failed' => 'ئەم بەکارهێنەرە نەدۆزرایەوە.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Sign up', diff --git a/lang/ku/entities.php b/lang/ku/entities.php index 5501d2bc229..58c00ec4b27 100644 --- a/lang/ku/entities.php +++ b/lang/ku/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Page Tags', 'chapter_tags' => 'Chapter Tags', 'book_tags' => 'Book Tags', diff --git a/lang/ku/settings.php b/lang/ku/settings.php index 3937c650f86..d03024a89d6 100644 --- a/lang/ku/settings.php +++ b/lang/ku/settings.php @@ -264,6 +264,9 @@ '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 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', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/lt/activities.php b/lang/lt/activities.php index a808171bb6d..0d08d41ead4 100644 --- a/lang/lt/activities.php +++ b/lang/lt/activities.php @@ -99,6 +99,8 @@ '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/lt/auth.php b/lang/lt/auth.php index a2f7f466846..8a91f2104ec 100644 --- a/lang/lt/auth.php +++ b/lang/lt/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Šie įgaliojimai neatitinka mūsų įrašų.', 'throttle' => 'Per daug prisijungimo bandymų. Prašome pabandyti dar kartą po :seconds sekundžių.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Užsiregistruoti', diff --git a/lang/lt/entities.php b/lang/lt/entities.php index f6610a22a7d..6bf86635aca 100644 --- a/lang/lt/entities.php +++ b/lang/lt/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Puslapio žymos', 'chapter_tags' => 'Skyriaus žymos', 'book_tags' => 'Knygos žymos', diff --git a/lang/lt/settings.php b/lang/lt/settings.php index 23dd38d50e6..96ee2bebada 100644 --- a/lang/lt/settings.php +++ b/lang/lt/settings.php @@ -264,6 +264,9 @@ '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 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' => 'Sukurti API sąsajos prieigos raktą', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/lv/activities.php b/lang/lv/activities.php index 38d9ae21766..8f902517031 100644 --- a/lang/lv/activities.php +++ b/lang/lv/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Lietotājs veiksmīgi atjaunināts', 'user_delete' => 'dzēsa lietotāju', 'user_delete_notification' => 'Lietotājs veiksmīgi dzēsts', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'izveidoja API žetonu', diff --git a/lang/lv/auth.php b/lang/lv/auth.php index 71b92ac0109..462ce110f8b 100644 --- a/lang/lv/auth.php +++ b/lang/lv/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Šie reģistrācijas dati neatbilst mūsu ierakstiem.', 'throttle' => 'Pārāk daudz pieteikšanās mēģinājumu. Lūdzu, mēģiniet vēlreiz pēc :seconds seconds.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Reģistrēties', diff --git a/lang/lv/entities.php b/lang/lv/entities.php index f0f32ab2174..dccab4bd114 100644 --- a/lang/lv/entities.php +++ b/lang/lv/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Pārslēgt sānjoslu', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Lapas birkas', 'chapter_tags' => 'Nodaļas birkas', 'book_tags' => 'Grāmatas birkas', diff --git a/lang/lv/settings.php b/lang/lv/settings.php index 0e11ebd65d6..886e0ef0a0c 100644 --- a/lang/lv/settings.php +++ b/lang/lv/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Iestati vairākfaktoru autentifikāciju kā papildus drošības līmeni tavam lietotāja kontam.', 'users_mfa_x_methods' => ':count metode iestatīta|:count metodes iestatītas', 'users_mfa_configure' => 'Iestatīt metodes', + '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' => 'Izveidot API žetonu', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/nb/activities.php b/lang/nb/activities.php index fd4de53216e..771f5438baa 100644 --- a/lang/nb/activities.php +++ b/lang/nb/activities.php @@ -100,6 +100,8 @@ 'user_update_notification' => 'Brukeren ble oppdatert', 'user_delete' => 'slettet bruker', 'user_delete_notification' => 'Brukeren ble fjernet', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'opprettet API-nøkkel', diff --git a/lang/nb/auth.php b/lang/nb/auth.php index 125e6daf5d4..f345f691ac9 100644 --- a/lang/nb/auth.php +++ b/lang/nb/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Disse detaljene samsvarer ikke med det vi har på bok.', 'throttle' => 'For mange forsøk, prøv igjen om :seconds sekunder.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registrer deg', diff --git a/lang/nb/entities.php b/lang/nb/entities.php index a67c35e1748..0e9070e53d5 100644 --- a/lang/nb/entities.php +++ b/lang/nb/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Bytt sidestolpe', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Sidemerker', 'chapter_tags' => 'Kapittelmerker', 'book_tags' => 'Bokmerker', diff --git a/lang/nb/settings.php b/lang/nb/settings.php index 5e779beead5..1cc5d8e02ca 100644 --- a/lang/nb/settings.php +++ b/lang/nb/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Konfigurer flerfaktorautentisering som et ekstra lag med sikkerhet for din konto.', 'users_mfa_x_methods' => ':count metode konfigurert|:count metoder konfigurert', 'users_mfa_configure' => 'Konfigurer metoder', + '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' => 'Opprett API-nøkkel', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/ne/activities.php b/lang/ne/activities.php index 4b4edf7be3a..24c2b948a4b 100644 --- a/lang/ne/activities.php +++ b/lang/ne/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'प्रयोगकर्ता सफलतापूर्वक अद्यावधिक गरियो', 'user_delete' => 'प्रयोगकर्ता हटाइयो', 'user_delete_notification' => 'प्रयोगकर्ता सफलतापूर्वक हटाइयो', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'API टोकन सिर्जना गरियो', diff --git a/lang/ne/auth.php b/lang/ne/auth.php index cd875a226cb..378f2dea28e 100644 --- a/lang/ne/auth.php +++ b/lang/ne/auth.php @@ -8,6 +8,7 @@ 'failed' => 'यी प्रमाणिकरण जानकारी हाम्रो अभिलेखसँग मेल खाँदैन।', 'throttle' => 'लगइन प्रयासहरूको संख्या धेरै भएको छ। कृपया :seconds सेकेन्ड पछि पुनः प्रयास गर्नुहोस्।', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'साइन अप गर्नुहोस्', diff --git a/lang/ne/entities.php b/lang/ne/entities.php index 4d9f78ea504..f51c265c576 100644 --- a/lang/ne/entities.php +++ b/lang/ne/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'साइडबार टगल गर्नुहोस्', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'पाना ट्यागहरू', 'chapter_tags' => 'अध्याय ट्यागहरू', 'book_tags' => 'पुस्तक ट्यागहरू', diff --git a/lang/ne/settings.php b/lang/ne/settings.php index f52bcd42026..549a4dc8b73 100644 --- a/lang/ne/settings.php +++ b/lang/ne/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'तपाईंको प्रयोगकर्ता खाताको लागि थप सुरक्षा तहको रूपमा बहु-फ्याक्टर प्रमाणीकरण सेटअप गर्नुहोस्।', 'users_mfa_x_methods' => ':count विधि सेटअप गरिएको|:count विधिहरू सेटअप गरिएको', 'users_mfa_configure' => 'विधिहरू सेटअप गर्नुहोस्', + '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' => 'API टोकन सिर्जना गर्नुहोस्', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/nl/activities.php b/lang/nl/activities.php index 230c7a58b3a..55356966ddc 100644 --- a/lang/nl/activities.php +++ b/lang/nl/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Gebruiker succesvol bijgewerkt', 'user_delete' => 'verwijderde gebruiker', 'user_delete_notification' => 'Gebruiker succesvol verwijderd', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'API-token aangemaakt', diff --git a/lang/nl/auth.php b/lang/nl/auth.php index 12c6b6c7bff..49d04dd4fbf 100644 --- a/lang/nl/auth.php +++ b/lang/nl/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Deze inloggegevens zijn niet bij ons bekend.', 'throttle' => 'Te veel inlogpogingen! Probeer het opnieuw na :seconds seconden.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registreer', diff --git a/lang/nl/entities.php b/lang/nl/entities.php index c39beef2de6..e90aff57573 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Zijbalk Tonen/Verbergen', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Pagina Labels', 'chapter_tags' => 'Hoofdstuk Labels', 'book_tags' => 'Boek Labels', diff --git a/lang/nl/settings.php b/lang/nl/settings.php index b82c52debba..9b0fe5ad154 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Stel meervoudige verificatie in als extra beveiligingslaag voor je gebruikersaccount.', 'users_mfa_x_methods' => ':count methode geconfigureerd|:count methoden geconfigureerd', 'users_mfa_configure' => 'Configureer methoden', + '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' => 'API-token aanmaken', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky (Slowaaks)', 'sl' => 'Slovenščina (Sloveens)', 'sv' => 'Svenska (Zweeds)', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe (Turks)', 'uk' => 'Українська (Oekraïens)', 'uz' => 'Oezbeeks', diff --git a/lang/nn/activities.php b/lang/nn/activities.php index f87aadc6677..fa934bf45fb 100644 --- a/lang/nn/activities.php +++ b/lang/nn/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Brukaren vart oppdatert', 'user_delete' => 'sletta brukar', 'user_delete_notification' => 'Brukaren vart fjerna', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'opprett API-nøkkel', diff --git a/lang/nn/auth.php b/lang/nn/auth.php index 5da686eaa9e..7e53aaee02c 100644 --- a/lang/nn/auth.php +++ b/lang/nn/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Desse detaljane samsvarar ikkje med det me har på bok.', 'throttle' => 'For mange forsøk, prøv på nytt om :seconds sekunder.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registrer deg', diff --git a/lang/nn/entities.php b/lang/nn/entities.php index 4f79f3d7518..878e2ef0faa 100644 --- a/lang/nn/entities.php +++ b/lang/nn/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Vis/gøym sidepanelet', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Sidemerker', 'chapter_tags' => 'Kapittelmerker', 'book_tags' => 'Bokmerker', diff --git a/lang/nn/settings.php b/lang/nn/settings.php index b6c322c9c34..08709833273 100644 --- a/lang/nn/settings.php +++ b/lang/nn/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Konfigurer flerfaktorautentisering som eit ekstra lag med tryggleik for din konto.', 'users_mfa_x_methods' => ':count metode konfigurert|:count metoder konfigurert', 'users_mfa_configure' => 'Konfigurer metoder', + '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' => 'Opprett API-nøkkel', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/pl/activities.php b/lang/pl/activities.php index 323b7035e1b..00687187d43 100644 --- a/lang/pl/activities.php +++ b/lang/pl/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Użytkownik zaktualizowany pomyślnie', 'user_delete' => 'usunięto użytkownika', 'user_delete_notification' => 'Użytkownik pomyślnie usunięty', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'utworzono token API', diff --git a/lang/pl/auth.php b/lang/pl/auth.php index 445f8198af8..7b4997a6cb0 100644 --- a/lang/pl/auth.php +++ b/lang/pl/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Wprowadzone poświadczenia są nieprawidłowe.', 'throttle' => 'Zbyt wiele prób logowania. Spróbuj ponownie za :seconds s.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Zarejestruj się', diff --git a/lang/pl/entities.php b/lang/pl/entities.php index 88e4344de40..ea0ac73670e 100644 --- a/lang/pl/entities.php +++ b/lang/pl/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Przełącz pasek boczny', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Tagi strony', 'chapter_tags' => 'Tagi rozdziału', 'book_tags' => 'Tagi książki', diff --git a/lang/pl/settings.php b/lang/pl/settings.php index 98201406e7b..bfa6339d551 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Skonfiguruj uwierzytelnianie wieloskładnikowe jako dodatkową warstwę bezpieczeństwa dla swojego konta użytkownika.', 'users_mfa_x_methods' => ':count metoda skonfigurowana|:count metody skonfigurowane', 'users_mfa_configure' => 'Konfiguruj metody', + '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' => 'Utwórz klucz API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/pt/activities.php b/lang/pt/activities.php index 33d9e2ca686..e2ed42c7111 100644 --- a/lang/pt/activities.php +++ b/lang/pt/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Utilizador atualizado com sucesso', 'user_delete' => 'utilizador eliminado', 'user_delete_notification' => 'Utilizador removido com sucesso', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'token API criado', diff --git a/lang/pt/auth.php b/lang/pt/auth.php index 8807e1a87d3..453b201689d 100644 --- a/lang/pt/auth.php +++ b/lang/pt/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Estas credenciais não coincidem com os nossos registos.', 'throttle' => 'Demasiadas tentativas de acesso. Tente novamente em :seconds segundos.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registar', diff --git a/lang/pt/entities.php b/lang/pt/entities.php index 709b8466aa7..3bdaeb12c8d 100644 --- a/lang/pt/entities.php +++ b/lang/pt/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Alternar barra lateral', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Etiquetas de Página', 'chapter_tags' => 'Etiquetas do Capítulo', 'book_tags' => 'Etiquetas do Livro', diff --git a/lang/pt/settings.php b/lang/pt/settings.php index 47fecbcbfee..1488db87a73 100644 --- a/lang/pt/settings.php +++ b/lang/pt/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Configure a autenticação multi-fatores como uma camada extra de segurança para sua conta de utilizador.', 'users_mfa_x_methods' => ':count método configurado|:count métodos configurados', 'users_mfa_configure' => 'Configurar Métodos', + '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' => 'Criar Token de API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/pt_BR/activities.php b/lang/pt_BR/activities.php index 2a2d173c790..e9069564f50 100644 --- a/lang/pt_BR/activities.php +++ b/lang/pt_BR/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Usuário atualizado com sucesso', 'user_delete' => 'usuário excluído', 'user_delete_notification' => 'Usuário removido com sucesso', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'token de API criado', diff --git a/lang/pt_BR/auth.php b/lang/pt_BR/auth.php index 0580542b49b..dfcc47da6f8 100644 --- a/lang/pt_BR/auth.php +++ b/lang/pt_BR/auth.php @@ -8,6 +8,7 @@ 'failed' => 'As credenciais fornecidas não puderam ser validadas em nossos registros.', 'throttle' => 'Muitas tentativas de login. Por favor, tente novamente em :seconds segundos.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Criar Conta', diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index 0211f45fa7c..7fa0b50c1d1 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => '', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Marcadores de Página', 'chapter_tags' => 'Marcadores de Capítulo', 'book_tags' => 'Marcadores de Livro', diff --git a/lang/pt_BR/settings.php b/lang/pt_BR/settings.php index 9b7c6a7d4f9..8129637c8dc 100644 --- a/lang/pt_BR/settings.php +++ b/lang/pt_BR/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'A autenticação multi-fator adiciona outra camada de segurança à sua conta.', 'users_mfa_x_methods' => ':count método configurado|:count métodos configurados', 'users_mfa_configure' => 'Configurar Métodos', + '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' => 'Criar Token de API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/ro/activities.php b/lang/ro/activities.php index eed5cc0832f..c00976cf710 100644 --- a/lang/ro/activities.php +++ b/lang/ro/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Utilizator actualizat cu succes', 'user_delete' => 'utilizator șters', 'user_delete_notification' => 'Utilizator eliminat cu succes', + '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/ro/auth.php b/lang/ro/auth.php index e02eeb4c1ba..73e16538183 100644 --- a/lang/ro/auth.php +++ b/lang/ro/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Aceste credenţiale nu se potrivesc cu înregistrările noastre.', 'throttle' => 'Prea multe încercări de conectare. Vă rugăm să încercați din nou în :seconds secunde.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Inregistrează-te', diff --git a/lang/ro/entities.php b/lang/ro/entities.php index aa18d09fba7..18ffd30c9e8 100644 --- a/lang/ro/entities.php +++ b/lang/ro/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Comutați bara laterală', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Etichete pagină', 'chapter_tags' => 'Etichete capitol', 'book_tags' => 'Etichete carte', diff --git a/lang/ro/settings.php b/lang/ro/settings.php index 117c4ca611a..bbbaa34d959 100644 --- a/lang/ro/settings.php +++ b/lang/ro/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Configurare autentificarea multi-factor ca un nivel suplimentar de securitate pentru contul tău de utilizator.', 'users_mfa_x_methods' => ':count metodă configurată|:count metode configurate', 'users_mfa_configure' => 'Configurare metode', + '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' => 'Creare token API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/ru/activities.php b/lang/ru/activities.php index d9ac1685ba9..9d08d1d4a19 100644 --- a/lang/ru/activities.php +++ b/lang/ru/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Пользователь успешно обновлен', 'user_delete' => 'удалил пользователя', 'user_delete_notification' => 'Пользователь успешно удален', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'создан API токен', diff --git a/lang/ru/auth.php b/lang/ru/auth.php index d39da119dea..433bfbe5fd6 100644 --- a/lang/ru/auth.php +++ b/lang/ru/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Введенные вами данные не найдены в нашей базе.', 'throttle' => 'Слишком много попыток входа. Пожалуйста, повторите попытку через :seconds секунд.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Регистрация', diff --git a/lang/ru/entities.php b/lang/ru/entities.php index 28d096fe802..d3c4230acd2 100644 --- a/lang/ru/entities.php +++ b/lang/ru/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Переключить боковую панель', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Теги страницы', 'chapter_tags' => 'Теги главы', 'book_tags' => 'Теги книги', diff --git a/lang/ru/settings.php b/lang/ru/settings.php index 7bf5832a369..4fdd5784ab2 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Многофакторная аутентификация повышает степень безопасности вашей учетной записи.', 'users_mfa_x_methods' => 'методов настроено :count|методов сконфигурировано :count', 'users_mfa_configure' => 'Настройка методов', + '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' => 'Создать токен', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/sk/activities.php b/lang/sk/activities.php index 65542fbe2a1..b9a532e2e05 100644 --- a/lang/sk/activities.php +++ b/lang/sk/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Používateľ úspešne upravený', 'user_delete' => 'odstránený používateľ', 'user_delete_notification' => 'Používateľ úspešne zmazaný', + '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/sk/auth.php b/lang/sk/auth.php index 11ccdc8893f..52f34f68f28 100644 --- a/lang/sk/auth.php +++ b/lang/sk/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Tieto údaje sa nezhodujú s našimi záznamami.', 'throttle' => 'Priveľa pokusov o prihlásenie. Skúste znova o :seconds sekúnd.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registrácia', diff --git a/lang/sk/entities.php b/lang/sk/entities.php index 48b662bf388..697aef0b875 100644 --- a/lang/sk/entities.php +++ b/lang/sk/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Štítky stránok', 'chapter_tags' => 'Štítky kapitol', 'book_tags' => 'Štítky kníh', diff --git a/lang/sk/settings.php b/lang/sk/settings.php index e18801ff467..e76fefb98e8 100644 --- a/lang/sk/settings.php +++ b/lang/sk/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Pre vyššiu úroveň bezpečnosti si nastavte viacúrovňové prihlasovanie.', 'users_mfa_x_methods' => ':count nakonfigurované metódy|:count nakonfigurovaných metód', 'users_mfa_configure' => 'Konfigurovať metódy', + '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' => 'Vytvoriť API token', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/sl/activities.php b/lang/sl/activities.php index 117c73ca186..3b8977c2f9a 100644 --- a/lang/sl/activities.php +++ b/lang/sl/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Uporabnik uspešno posodobljen', 'user_delete' => 'uporabnik izbrisan', 'user_delete_notification' => 'Uporabnik uspešno izbrisan', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'ustvarjen žeton API', diff --git a/lang/sl/auth.php b/lang/sl/auth.php index e06d823e7cd..46c6a565d24 100644 --- a/lang/sl/auth.php +++ b/lang/sl/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Poverilnice se ne ujemajo s podatki v naši bazi.', 'throttle' => 'Prekoračili ste število možnih prijav. Poskusite znova čez :seconds sekund.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Registracija', diff --git a/lang/sl/entities.php b/lang/sl/entities.php index 86a43132ef6..0efbc6d17c9 100644 --- a/lang/sl/entities.php +++ b/lang/sl/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Oznake strani', 'chapter_tags' => 'Oznake poglavja', 'book_tags' => 'Oznake knjige', diff --git a/lang/sl/settings.php b/lang/sl/settings.php index 87c1e8e6db2..9bd63a0e88e 100644 --- a/lang/sl/settings.php +++ b/lang/sl/settings.php @@ -264,6 +264,9 @@ '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 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' => 'Ustvari žeton', @@ -365,6 +368,7 @@ 'sk' => 'Slovensky', 'sl' => 'slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/sq/activities.php b/lang/sq/activities.php index 9296d1759ca..2d41a11218b 100644 --- a/lang/sq/activities.php +++ b/lang/sq/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Përdoruesi u përditësua me sukses', 'user_delete' => 'fshi përdorues', 'user_delete_notification' => 'Përdoruesi u fshi me sukses', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'Krijoi token API', diff --git a/lang/sq/auth.php b/lang/sq/auth.php index 6fdc4ee4a6d..d5bf7acfb58 100644 --- a/lang/sq/auth.php +++ b/lang/sq/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Këto kredenciale nuk përputhen me të dhënat tona.', 'throttle' => 'Shumë përpjekje për hyrje. Ju lutemi provoni përsëri në :seconds sekonda.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Regjistrohu', diff --git a/lang/sq/entities.php b/lang/sq/entities.php index 5501d2bc229..58c00ec4b27 100644 --- a/lang/sq/entities.php +++ b/lang/sq/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Page Tags', 'chapter_tags' => 'Chapter Tags', 'book_tags' => 'Book Tags', diff --git a/lang/sq/settings.php b/lang/sq/settings.php index 3937c650f86..d03024a89d6 100644 --- a/lang/sq/settings.php +++ b/lang/sq/settings.php @@ -264,6 +264,9 @@ '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 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', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/sr/activities.php b/lang/sr/activities.php index b4aa5e2a7f4..555d01adca1 100644 --- a/lang/sr/activities.php +++ b/lang/sr/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Корисник је успешно ажуриран', 'user_delete' => 'избрисан корисника', 'user_delete_notification' => 'Корисник је успешно уклоњен', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'креирао апи токен', diff --git a/lang/sr/auth.php b/lang/sr/auth.php index 0100e666fa3..96169fe2bab 100644 --- a/lang/sr/auth.php +++ b/lang/sr/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Ови акредитиви се не поклапају са нашом евиденцијом.', 'throttle' => 'Превише покушаја пријаве. Покушајте поново за :seconds секунди.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Региструј се', diff --git a/lang/sr/entities.php b/lang/sr/entities.php index 8f9c40e91a3..cd4e732f412 100644 --- a/lang/sr/entities.php +++ b/lang/sr/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Page Tags', 'chapter_tags' => 'Chapter Tags', 'book_tags' => 'Book Tags', diff --git a/lang/sr/settings.php b/lang/sr/settings.php index 3453bc344b3..143fdaef19e 100644 --- a/lang/sr/settings.php +++ b/lang/sr/settings.php @@ -264,6 +264,9 @@ '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 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', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/sv/activities.php b/lang/sv/activities.php index 3acf6d4ff8d..c501c675273 100644 --- a/lang/sv/activities.php +++ b/lang/sv/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Användaren har uppdaterats', 'user_delete' => 'raderad användare', 'user_delete_notification' => 'Användaren har tagits bort', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'skapade API-token', diff --git a/lang/sv/auth.php b/lang/sv/auth.php index c9feb831218..6c94f21f9e0 100644 --- a/lang/sv/auth.php +++ b/lang/sv/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Uppgifterna stämmer inte överens med våra register.', 'throttle' => 'För många inloggningsförsök. Prova igen om :seconds sekunder.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Skapa konto', diff --git a/lang/sv/entities.php b/lang/sv/entities.php index 94fc23f501a..1df81684913 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Visa/Dölj sidopanel', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Sidtaggar', 'chapter_tags' => 'Kapiteltaggar', 'book_tags' => 'Boktaggar', diff --git a/lang/sv/settings.php b/lang/sv/settings.php index 47f602b8df8..e900a86a3ba 100644 --- a/lang/sv/settings.php +++ b/lang/sv/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Konfigurera multifaktorsautentisering som ett extra skydd för ditt konto.', 'users_mfa_x_methods' => ':count metod konfigurerad|:count metoder konfigurerade', 'users_mfa_configure' => 'Konfigurera metoder', + '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' => 'Skapa API-nyckel', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenska', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/th/activities.php b/lang/th/activities.php index 6d84813d6ac..0ea18ca0a6b 100644 --- a/lang/th/activities.php +++ b/lang/th/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'แก้ไขผู้ใช้สำเร็จแล้ว', 'user_delete' => 'ลบผู้ใช้', 'user_delete_notification' => 'ลบผู้ใช้สำเร็จแล้ว', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'สร้าง API Token', diff --git a/lang/th/auth.php b/lang/th/auth.php index c00b29bcd88..ca24b593c47 100644 --- a/lang/th/auth.php +++ b/lang/th/auth.php @@ -8,6 +8,7 @@ 'failed' => 'ข้อมูลประจำตัวไม่ตรงกับที่มีในระบบ', 'throttle' => 'เข้าสู่ระบบล้มเหลวหลายครั้งเกินไป กรุณาลองใหม่ในอีก :seconds วินาที', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'สมัครสมาชิก', diff --git a/lang/th/entities.php b/lang/th/entities.php index 1f0f30ec6ff..bda46b62c59 100644 --- a/lang/th/entities.php +++ b/lang/th/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'แสดง/ซ่อนแถบด้านข้าง', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'แท็กหน้า', 'chapter_tags' => 'แท็กบท', 'book_tags' => 'แท็กหนังสือ', diff --git a/lang/th/settings.php b/lang/th/settings.php index 1b90d2a7196..558d2da0f6e 100644 --- a/lang/th/settings.php +++ b/lang/th/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'ตั้งค่าการยืนยันตัวตนแบบหลายขั้นตอนเป็นชั้นความปลอดภัยเพิ่มเติมสำหรับบัญชีผู้ใช้ของคุณ', 'users_mfa_x_methods' => 'กำหนดค่า :count วิธี', 'users_mfa_configure' => 'กำหนดค่าวิธี', + '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' => 'สร้าง API Token', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/tk/activities.php b/lang/tk/activities.php index 4362fc02958..e9344a3d477 100644 --- a/lang/tk/activities.php +++ b/lang/tk/activities.php @@ -99,6 +99,8 @@ '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/tk/auth.php b/lang/tk/auth.php index 57f0cb5c632..47be4ea721e 100644 --- a/lang/tk/auth.php +++ b/lang/tk/auth.php @@ -8,6 +8,7 @@ 'failed' => 'These credentials do not match our records.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Sign up', diff --git a/lang/tk/entities.php b/lang/tk/entities.php index 5501d2bc229..58c00ec4b27 100644 --- a/lang/tk/entities.php +++ b/lang/tk/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Page Tags', 'chapter_tags' => 'Chapter Tags', 'book_tags' => 'Book Tags', diff --git a/lang/tk/settings.php b/lang/tk/settings.php index 3937c650f86..d03024a89d6 100644 --- a/lang/tk/settings.php +++ b/lang/tk/settings.php @@ -264,6 +264,9 @@ '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 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', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/tr/activities.php b/lang/tr/activities.php index 5bed9ea303b..71282e448d7 100644 --- a/lang/tr/activities.php +++ b/lang/tr/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Kullanıcı başarıyla güncellendi', 'user_delete' => 'kullanıcı silindi', 'user_delete_notification' => 'Kullanıcı başarıyla silindi', + '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/tr/auth.php b/lang/tr/auth.php index c9663f21e00..f8942a4dfc3 100644 --- a/lang/tr/auth.php +++ b/lang/tr/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Girdiğiniz bilgiler kayıtlarımızla uyuşmuyor.', 'throttle' => 'Çok fazla giriş yapmaya çalıştınız. Lütfen :seconds saniye içinde tekrar deneyin.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Kaydol', diff --git a/lang/tr/entities.php b/lang/tr/entities.php index 3d271ecb52f..e5bdacae791 100644 --- a/lang/tr/entities.php +++ b/lang/tr/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Sayfa Etiketleri', 'chapter_tags' => 'Bölüm Etiketleri', 'book_tags' => 'Kitap Etiketleri', diff --git a/lang/tr/settings.php b/lang/tr/settings.php index af8d2c6494b..39ae23f2845 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -264,6 +264,9 @@ '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' => 'Yöntemleri Yapılandır', + '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' => 'API Anahtarı Oluştur', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovence', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/uk/activities.php b/lang/uk/activities.php index e8b77e0d430..140fec47c7e 100644 --- a/lang/uk/activities.php +++ b/lang/uk/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Користувача було успішно оновлено', 'user_delete' => 'вилучений користувач', 'user_delete_notification' => 'Користувача успішно видалено', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'створений APi токен', diff --git a/lang/uk/auth.php b/lang/uk/auth.php index 27aae39367e..cb927cfd47f 100644 --- a/lang/uk/auth.php +++ b/lang/uk/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Цей обліковий запис не знайдено.', 'throttle' => 'Забагато спроб входу в систему. Будь ласка, спробуйте ще раз через :seconds секунд.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Реєстрація', diff --git a/lang/uk/entities.php b/lang/uk/entities.php index 6bf7b241ef1..a77b8e77d13 100644 --- a/lang/uk/entities.php +++ b/lang/uk/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Перемикач бічної панелі', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Теги сторінки', 'chapter_tags' => 'Теги розділів', 'book_tags' => 'Теги книг', diff --git a/lang/uk/settings.php b/lang/uk/settings.php index 9706b328b3b..02fd6a4f6ee 100644 --- a/lang/uk/settings.php +++ b/lang/uk/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Двофакторна аутентифікація додає ще один рівень безпеки для вашого облікового запису.', 'users_mfa_x_methods' => ':count метод налаштовано|:count методів налаштовано', 'users_mfa_configure' => 'Налаштувати Методи', + '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' => 'Створити токен API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/uz/activities.php b/lang/uz/activities.php index 84764192d77..f87a52870fa 100644 --- a/lang/uz/activities.php +++ b/lang/uz/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Foydalanuvchi muvaffaqiyatli yangilandi', 'user_delete' => 'deleted user', 'user_delete_notification' => 'Foydalanuvchi muvaffaqiyatli olib tashlandi', + '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/uz/auth.php b/lang/uz/auth.php index 59a620daeca..1c31d8f870a 100644 --- a/lang/uz/auth.php +++ b/lang/uz/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Uchbu ma‘lumotlar, bizdagi ma‘lumotlarga mos kelmadi.', 'throttle' => 'Kirishga urinishlar juda ko‘p. Iltimos :seconds soniyadan so‘ng urinib ko‘ring.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Ro‘yxatdan o‘tish', diff --git a/lang/uz/entities.php b/lang/uz/entities.php index d3fdb594421..f8702892617 100644 --- a/lang/uz/entities.php +++ b/lang/uz/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Toggle Sidebar', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Sahifa teglari', 'chapter_tags' => 'Bo\'lim teglari', 'book_tags' => 'Kitob teglari', diff --git a/lang/uz/settings.php b/lang/uz/settings.php index 0dbf8351e83..c1d3b3d46e1 100644 --- a/lang/uz/settings.php +++ b/lang/uz/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Ko\'p faktorli autentifikatsiyani foydalanuvchi hisobingiz uchun qo\'shimcha xavfsizlik qatlami sifatida o\'rnating.', 'users_mfa_x_methods' => ':count usuli tuzilgan|:count usullari sozlangan', 'users_mfa_configure' => 'Usullarni sozlash', + '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' => 'API tokenini yarating', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/vi/activities.php b/lang/vi/activities.php index 00ec6ce04d5..0625bb529a5 100644 --- a/lang/vi/activities.php +++ b/lang/vi/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => 'Người dùng được cập nhật thành công', 'user_delete' => 'người dùng đã bị xóa', 'user_delete_notification' => 'Người dùng đã được xóa thành công', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => 'Đã tạo Token API ', diff --git a/lang/vi/auth.php b/lang/vi/auth.php index d53d2cf4cb6..9e0ce731b25 100644 --- a/lang/vi/auth.php +++ b/lang/vi/auth.php @@ -8,6 +8,7 @@ 'failed' => 'Thông tin đăng nhập này không khớp với dữ liệu của chúng tôi.', 'throttle' => 'Quá nhiều lần đăng nhập sai. Vui lòng thử lại sau :seconds giây.', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => 'Đăng ký', diff --git a/lang/vi/entities.php b/lang/vi/entities.php index fc1ca6d5620..a3f92e03024 100644 --- a/lang/vi/entities.php +++ b/lang/vi/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Chuyển đổi thanh bên', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Các Thẻ Trang', 'chapter_tags' => 'Các Thẻ Chương', 'book_tags' => 'Các Thẻ Sách', diff --git a/lang/vi/settings.php b/lang/vi/settings.php index c0bca56914c..d029fe7f329 100644 --- a/lang/vi/settings.php +++ b/lang/vi/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => 'Thiết lập xác thực đa yếu tố như một lớp bảo mật bổ sung cho tài khoản người dùng của bạn.', 'users_mfa_x_methods' => ':count phương thức đã cấu hình|:count phương thức đã cấu hình', 'users_mfa_configure' => 'Cấu hình phương thức', + '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' => 'Tạo Token API', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/zh_CN/activities.php b/lang/zh_CN/activities.php index a2a2acd24f2..d30e61f0f2a 100644 --- a/lang/zh_CN/activities.php +++ b/lang/zh_CN/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => '用户更新成功', 'user_delete' => '用户已删除', 'user_delete_notification' => '成功移除用户', + 'user_mfa_reset' => '为用户重置MFA', + 'user_mfa_reset_notification' => '多因素认证方法已重置', // API Tokens 'api_token_create' => '已创建 API 令牌', diff --git a/lang/zh_CN/auth.php b/lang/zh_CN/auth.php index 8c4ba63f9e2..4c97b46ce02 100644 --- a/lang/zh_CN/auth.php +++ b/lang/zh_CN/auth.php @@ -8,6 +8,7 @@ 'failed' => '用户名或密码错误。', 'throttle' => '您的登录次数过多,请在:seconds秒后重试。', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => '注册', diff --git a/lang/zh_CN/entities.php b/lang/zh_CN/entities.php index 9d57eeb5719..9daf816da04 100644 --- a/lang/zh_CN/entities.php +++ b/lang/zh_CN/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => '切换侧边栏', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => '页面标签', 'chapter_tags' => '章节标签', 'book_tags' => '书籍标签', diff --git a/lang/zh_CN/settings.php b/lang/zh_CN/settings.php index 86dd680b02c..eef14b1687d 100644 --- a/lang/zh_CN/settings.php +++ b/lang/zh_CN/settings.php @@ -264,6 +264,9 @@ 'users_mfa_desc' => '设置多重身份认证能增加您账户的安全性。', 'users_mfa_x_methods' => ':count 个措施已配置|:count 个措施已配置', 'users_mfa_configure' => '配置安全措施', + 'users_mfa_reset' => '重置多重身份验证方法', + 'users_mfa_reset_desc' => '此操作将重置并清除该用户所有已配置的多重身份验证方法。如果多重身份验证是任何角色所要求的,用户在下次登录时将被提示配置新的方法。', + 'users_mfa_reset_confirm' => '您确定要重置此用户的多重身份验证吗?', // API Tokens 'user_api_token_create' => '创建 API 令牌', @@ -364,6 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/zh_TW/activities.php b/lang/zh_TW/activities.php index 65ba0a56597..791fa26b752 100644 --- a/lang/zh_TW/activities.php +++ b/lang/zh_TW/activities.php @@ -99,6 +99,8 @@ 'user_update_notification' => '使用者已成功更新。', 'user_delete' => '已刪除使用者', 'user_delete_notification' => '使用者移除成功', + 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens 'api_token_create' => '建立 API 權杖', diff --git a/lang/zh_TW/auth.php b/lang/zh_TW/auth.php index 1168b48b270..47e8ed950b2 100644 --- a/lang/zh_TW/auth.php +++ b/lang/zh_TW/auth.php @@ -8,6 +8,7 @@ 'failed' => '使用者名稱或密碼錯誤。', 'throttle' => '您的登入次數過多,請在 :seconds 秒後重試。', + 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', // Login & Register 'sign_up' => '註冊', diff --git a/lang/zh_TW/entities.php b/lang/zh_TW/entities.php index 08886013c43..ba53d885dd4 100644 --- a/lang/zh_TW/entities.php +++ b/lang/zh_TW/entities.php @@ -331,6 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => '切換側邊欄', + 'page_contents' => 'Page Contents', + 'page_contents_none' => 'No headings were found in the page content.', + 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => '頁面標籤', 'chapter_tags' => '章節標籤', 'book_tags' => '書本標籤', diff --git a/lang/zh_TW/settings.php b/lang/zh_TW/settings.php index fd5b088f5f8..95108545019 100644 --- a/lang/zh_TW/settings.php +++ b/lang/zh_TW/settings.php @@ -265,6 +265,9 @@ 'users_mfa_desc' => '設定多重身份驗證為您的帳戶多增加了一道防線', 'users_mfa_x_methods' => ':count 個措施已配置|:count 個措施已配置', 'users_mfa_configure' => '方式設置', + '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' => '建立 API 權杖', @@ -365,6 +368,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', + 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', From 67529dd883cf9e70f8e362a9fcfc6edcc4d19a96 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 28 May 2026 10:34:04 +0100 Subject: [PATCH 257/398] Deps: Updated PHP packages, fixed some types for phpstan --- app/Search/SearchIndex.php | 10 +- composer.lock | 279 +++++++++++++++++++------------------ 2 files changed, 150 insertions(+), 139 deletions(-) diff --git a/app/Search/SearchIndex.php b/app/Search/SearchIndex.php index ce78831eeae..e2a4a21a472 100644 --- a/app/Search/SearchIndex.php +++ b/app/Search/SearchIndex.php @@ -119,7 +119,7 @@ protected function insertTerms(array $terms): void * Create a scored term array from the given text, where the keys are the terms * and the values are their scores. * - * @return array + * @return array */ protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array { @@ -136,7 +136,7 @@ protected function generateTermScoreMapFromText(string $text, float $scoreAdjust * Create a scored term array from the given HTML, where the keys are the terms * and the values are their scores. * - * @return array + * @return array */ protected function generateTermScoreMapFromHtml(string $html): array { @@ -177,7 +177,7 @@ protected function generateTermScoreMapFromHtml(string $html): array * * @param Tag[] $tags * - * @return array + * @return array */ protected function generateTermScoreMapFromTags(array $tags): array { @@ -277,9 +277,9 @@ protected function entityToTermDataArray(Entity $entity): array * For the given term data arrays, Merge their contents by term * while combining any scores. * - * @param array[] ...$scoreMaps + * @param array[] ...$scoreMaps * - * @return array + * @return array */ protected function mergeTermScoreMaps(...$scoreMaps): array { diff --git a/composer.lock b/composer.lock index c0346a7b27c..710067eb0a6 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.381.5", + "version": "3.382.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "409208d62af0ddafbcb0af1a0bf514f5ffcaba92" + "reference": "6844cc6421c47d6b96633ab8039045012acbeb27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/409208d62af0ddafbcb0af1a0bf514f5ffcaba92", - "reference": "409208d62af0ddafbcb0af1a0bf514f5ffcaba92", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/6844cc6421c47d6b96633ab8039045012acbeb27", + "reference": "6844cc6421c47d6b96633ab8039045012acbeb27", "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.381.5" + "source": "https://github.com/aws/aws-sdk-php/tree/3.382.2" }, - "time": "2026-05-20T18:16:01+00:00" + "time": "2026-05-27T18:11:41+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1179,16 +1179,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.3", + "version": "7.10.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86" + "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", - "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7c8d84b39e680315f687e8662a9d6fb0865c5148", + "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148", "shasum": "" }, "require": { @@ -1206,7 +1206,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.3.2", + "guzzlehttp/test-server": "^0.4", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1286,7 +1286,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.3" + "source": "https://github.com/guzzle/guzzle/tree/7.10.5" }, "funding": [ { @@ -1302,7 +1302,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T22:59:19+00:00" + "time": "2026-05-27T11:53:46+00:00" }, { "name": "guzzlehttp/promises", @@ -1389,16 +1389,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.10.1", + "version": "2.10.3", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" + "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", - "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7c1472269227dc6f18930bd903d7a88fe6c52130", + "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130", "shasum": "" }, "require": { @@ -1486,7 +1486,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.10.1" + "source": "https://github.com/guzzle/psr7/tree/2.10.3" }, "funding": [ { @@ -1502,20 +1502,20 @@ "type": "tidelift" } ], - "time": "2026-05-20T09:27:36+00:00" + "time": "2026-05-27T11:48:20+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.5", + "version": "v1.0.6", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", "shasum": "" }, "require": { @@ -1524,7 +1524,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "uri-template/tests": "1.0.0" }, "type": "library", @@ -1572,7 +1572,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" }, "funding": [ { @@ -1588,7 +1588,7 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:27:06+00:00" + "time": "2026-05-23T22:00:21+00:00" }, { "name": "intervention/gif", @@ -1803,16 +1803,16 @@ }, { "name": "laravel/framework", - "version": "v12.60.2", + "version": "v12.61.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39" + "reference": "1124062a1ca92d290c8bcb9b7f649920fa6816bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/b8b55ce32175cc00f834a56eeb6316f18ed6ea39", - "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39", + "url": "https://api.github.com/repos/laravel/framework/zipball/1124062a1ca92d290c8bcb9b7f649920fa6816bf", + "reference": "1124062a1ca92d290c8bcb9b7f649920fa6816bf", "shasum": "" }, "require": { @@ -2021,7 +2021,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-20T11:48:19+00:00" + "time": "2026-05-26T23:41:33+00:00" }, { "name": "laravel/prompts", @@ -4666,16 +4666,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.22", + "version": "v0.12.23", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", - "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", "shasum": "" }, "require": { @@ -4739,9 +4739,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" }, - "time": "2026-03-22T23:03:24+00:00" + "time": "2026-05-23T13:41:31+00:00" }, { "name": "ralouphie/getallheaders", @@ -5511,16 +5511,16 @@ }, { "name": "symfony/console", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", - "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", "shasum": "" }, "require": { @@ -5585,7 +5585,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.11" + "source": "https://github.com/symfony/console/tree/v7.4.13" }, "funding": [ { @@ -5605,7 +5605,7 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-05-24T08:56:14+00:00" }, { "name": "symfony/css-selector", @@ -6134,16 +6134,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.8", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab" + "reference": "bc354f47c62301e990b7874fa662326368508e2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", "shasum": "" }, "require": { @@ -6192,7 +6192,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.8" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" }, "funding": [ { @@ -6212,20 +6212,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d" + "reference": "9df847980c436451f4f51d1284491bb4356dd989" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", - "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", + "reference": "9df847980c436451f4f51d1284491bb4356dd989", "shasum": "" }, "require": { @@ -6311,7 +6311,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.12" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" }, "funding": [ { @@ -6331,7 +6331,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T09:27:11+00:00" + "time": "2026-05-27T08:31:43+00:00" }, { "name": "symfony/mailer", @@ -6419,16 +6419,16 @@ }, { "name": "symfony/mime", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470" + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470", - "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", "shasum": "" }, "require": { @@ -6484,7 +6484,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.12" + "source": "https://github.com/symfony/mime/tree/v7.4.13" }, "funding": [ { @@ -6504,7 +6504,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-23T16:22:37+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6591,16 +6591,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -6649,7 +6649,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -6669,20 +6669,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -6736,7 +6736,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -6756,20 +6756,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -6821,7 +6821,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -6841,20 +6841,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -6906,7 +6906,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -6926,7 +6926,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php80", @@ -7014,16 +7014,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" + "reference": "8339098cae28673c15cce00d80734af0453054e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", "shasum": "" }, "require": { @@ -7070,7 +7070,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" }, "funding": [ { @@ -7090,20 +7090,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -7150,7 +7150,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -7170,20 +7170,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -7230,7 +7230,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -7250,7 +7250,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:10:57+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/polyfill-uuid", @@ -7337,16 +7337,16 @@ }, { "name": "symfony/process", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0" + "reference": "f5804be144caceb570f6747519999636b664f24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0", - "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", "shasum": "" }, "require": { @@ -7378,7 +7378,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.11" + "source": "https://github.com/symfony/process/tree/v7.4.13" }, "funding": [ { @@ -7398,20 +7398,20 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:55:21+00:00" + "time": "2026-05-23T16:05:06+00:00" }, { "name": "symfony/routing", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204" + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", - "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", "shasum": "" }, "require": { @@ -7463,7 +7463,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.12" + "source": "https://github.com/symfony/routing/tree/v7.4.13" }, "funding": [ { @@ -7483,7 +7483,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/service-contracts", @@ -7574,16 +7574,16 @@ }, { "name": "symfony/string", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/965f7306a43383d02c6aca1e3f3bd2f0ea5dee15", - "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { @@ -7641,7 +7641,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.11" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -7661,7 +7661,7 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { "name": "symfony/translation", @@ -8732,16 +8732,16 @@ }, { "name": "larastan/larastan", - "version": "v3.9.6", + "version": "v3.10.0", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636" + "reference": "2970f83398154178a739609c244577267c7ee8eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", - "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", + "reference": "2970f83398154178a739609c244577267c7ee8eb", "shasum": "" }, "require": { @@ -8755,17 +8755,17 @@ "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.44" + "phpstan/phpstan": "^2.2.0" }, "require-dev": { - "doctrine/coding-standard": "^13", + "doctrine/coding-standard": "^14", "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", "mockery/mockery": "^1.6.12", "nikic/php-parser": "^5.4", "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", "phpstan/phpstan-deprecation-rules": "^2.0.1", - "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8" + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" }, "suggest": { "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", @@ -8810,7 +8810,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.9.6" + "source": "https://github.com/larastan/larastan/tree/v3.10.0" }, "funding": [ { @@ -8818,7 +8818,7 @@ "type": "github" } ], - "time": "2026-04-16T10:02:43+00:00" + "time": "2026-05-28T08:00:58+00:00" }, { "name": "mockery/mockery", @@ -9179,11 +9179,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.55", + "version": "2.2.0", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", - "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b4cd98348c809924f62bb9cc8c047f5e73bc9a58", + "reference": "b4cd98348c809924f62bb9cc8c047f5e73bc9a58", "shasum": "" }, "require": { @@ -9206,6 +9206,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -9228,7 +9239,7 @@ "type": "github" } ], - "time": "2026-05-18T11:57:34+00:00" + "time": "2026-05-28T08:22:43+00:00" }, { "name": "phpunit/php-code-coverage", From d421a191935e9689709f5a949c3d23dfd87d6ecc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 28 May 2026 12:32:17 +0100 Subject: [PATCH 258/398] Updated translator & dependency attribution before release v26.05 --- .github/translators.txt | 9 +- dev/licensing/js-library-licenses.txt | 241 +++++++------------------ dev/licensing/php-library-licenses.txt | 2 +- 3 files changed, 75 insertions(+), 177 deletions(-) diff --git a/.github/translators.txt b/.github/translators.txt index 037887bcc36..cae26547bd3 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -533,7 +533,14 @@ JanDziaslo :: Polish Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian Ilgiz Zigangirov (inov8) :: Russian Max Israelsson (Blezie) :: Swedish -Skiddybison5924 (chris-devel0per) :: German +Skiddybison5924 (chris-devel0per) :: German Informal; German Veyilla Nightwhisper (Veyilla) :: German João Barbosa (hypeedd) :: Portuguese Abcdefg Hijklmn (collatek) :: Korean +Suthep Yonphimai (tomztt) :: Thai +MrClock (MrClock8163) :: Hungarian +Elena0875 :: Russian +FelixFrizzy :: German +Pedro de Mattia (pdmtt) :: Portuguese, Brazilian +lonestan :: Russian +Paul Kernstock (kernstock) :: German diff --git a/dev/licensing/js-library-licenses.txt b/dev/licensing/js-library-licenses.txt index 1c23c093762..45d1816dd17 100644 --- a/dev/licensing/js-library-licenses.txt +++ b/dev/licensing/js-library-licenses.txt @@ -69,8 +69,9 @@ Source: zeit/arg Link: zeit/arg ----------- argparse -License: Python-2.0 +License: MIT License File: node_modules/argparse/LICENSE +Copyright: Copyright (C) 2012 by Vitaly Puzrin Source: nodeca/argparse Link: nodeca/argparse ----------- @@ -81,34 +82,6 @@ Copyright: Copyright (c) 2023 Inspect JS Source: git+https://github.com/inspect-js/array-buffer-byte-length.git Link: https://github.com/inspect-js/array-buffer-byte-length#readme ----------- -array-includes -License: MIT -License File: node_modules/array-includes/LICENSE -Copyright: Copyright (C) 2015 Jordan Harband -Source: git://github.com/es-shims/array-includes.git -Link: git://github.com/es-shims/array-includes.git ------------ -array.prototype.findlastindex -License: MIT -License File: node_modules/array.prototype.findlastindex/LICENSE -Copyright: Copyright (c) 2021 ECMAScript Shims -Source: git+https://github.com/es-shims/Array.prototype.findLastIndex.git -Link: https://github.com/es-shims/Array.prototype.findLastIndex#readme ------------ -array.prototype.flat -License: MIT -License File: node_modules/array.prototype.flat/LICENSE -Copyright: Copyright (c) 2017 ECMAScript Shims -Source: git://github.com/es-shims/Array.prototype.flat.git -Link: git://github.com/es-shims/Array.prototype.flat.git ------------ -array.prototype.flatmap -License: MIT -License File: node_modules/array.prototype.flatmap/LICENSE -Copyright: Copyright (c) 2017 ECMAScript Shims -Source: git://github.com/es-shims/Array.prototype.flatMap.git -Link: git://github.com/es-shims/Array.prototype.flatMap.git ------------ arraybuffer.prototype.slice License: MIT License File: node_modules/arraybuffer.prototype.slice/LICENSE @@ -169,9 +142,14 @@ Link: https://github.com/jestjs/jest.git balanced-match License: MIT License File: node_modules/balanced-match/LICENSE.md -Copyright: Copyright (c) 2013 Julian Gruber <******@************.***> Source: git://github.com/juliangruber/balanced-match.git -Link: https://github.com/juliangruber/balanced-match +Link: git://github.com/juliangruber/balanced-match.git +----------- +baseline-browser-mapping +License: Apache-2.0 +License File: node_modules/baseline-browser-mapping/LICENSE.txt +Source: git+https://github.com/web-platform-dx/baseline-browser-mapping.git +Link: git+https://github.com/web-platform-dx/baseline-browser-mapping.git ----------- binary-extensions License: MIT @@ -184,9 +162,8 @@ Link: sindresorhus/binary-extensions brace-expansion License: MIT License File: node_modules/brace-expansion/LICENSE -Copyright: Copyright (c) 2013 Julian Gruber <******@************.***> -Source: git://github.com/juliangruber/brace-expansion.git -Link: https://github.com/juliangruber/brace-expansion +Source: git+ssh://git@github.com/juliangruber/brace-expansion.git +Link: git+ssh://git@github.com/juliangruber/brace-expansion.git ----------- braces License: MIT @@ -472,8 +449,8 @@ Link: git://github.com/ljharb/define-properties.git detect-libc License: Apache-2.0 License File: node_modules/detect-libc/LICENSE -Source: git://github.com/lovell/detect-libc -Link: git://github.com/lovell/detect-libc +Source: git://github.com/lovell/detect-libc.git +Link: git://github.com/lovell/detect-libc.git ----------- detect-newline License: MIT @@ -489,12 +466,6 @@ Copyright: Copyright (c) 2009-2015, Kevin Decker <********@*****.***> Source: git://github.com/kpdecker/jsdiff.git Link: git://github.com/kpdecker/jsdiff.git ----------- -doctrine -License: Apache-2.0 -License File: node_modules/doctrine/LICENSE -Source: eslint/doctrine -Link: https://github.com/eslint/doctrine ------------ dunder-proto License: MIT License File: node_modules/dunder-proto/LICENSE @@ -511,8 +482,8 @@ electron-to-chromium License: ISC License File: node_modules/electron-to-chromium/LICENSE Copyright: Copyright 2018 Kilian Valkhof -Source: https://github.com/kilian/electron-to-chromium/ -Link: https://github.com/kilian/electron-to-chromium/ +Source: git+https://github.com/kilian/electron-to-chromium.git +Link: git+https://github.com/kilian/electron-to-chromium.git ----------- emittery License: MIT @@ -577,13 +548,6 @@ Copyright: Copyright (c) 2022 ECMAScript Shims Source: git+https://github.com/es-shims/es-set-tostringtag.git Link: https://github.com/es-shims/es-set-tostringtag#readme ----------- -es-shim-unscopables -License: MIT -License File: node_modules/es-shim-unscopables/LICENSE -Copyright: Copyright (c) 2022 Jordan Harband -Source: git+https://github.com/ljharb/es-shim-unscopables.git -Link: https://github.com/ljharb/es-shim-unscopables#readme ------------ es-to-primitive License: MIT License File: node_modules/es-to-primitive/LICENSE @@ -612,27 +576,6 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindres Source: sindresorhus/escape-string-regexp Link: sindresorhus/escape-string-regexp ----------- -eslint-import-resolver-node -License: MIT -License File: node_modules/eslint-import-resolver-node/LICENSE -Copyright: Copyright (c) 2015 Ben Mosher -Source: https://github.com/import-js/eslint-plugin-import -Link: https://github.com/import-js/eslint-plugin-import ------------ -eslint-module-utils -License: MIT -License File: node_modules/eslint-module-utils/LICENSE -Copyright: Copyright (c) 2015 Ben Mosher -Source: git+https://github.com/import-js/eslint-plugin-import.git -Link: https://github.com/import-js/eslint-plugin-import#readme ------------ -eslint-plugin-import -License: MIT -License File: node_modules/eslint-plugin-import/LICENSE -Copyright: Copyright (c) 2015 Ben Mosher -Source: https://github.com/import-js/eslint-plugin-import -Link: https://github.com/import-js/eslint-plugin-import ------------ eslint-scope License: BSD-2-Clause License File: node_modules/eslint-scope/LICENSE @@ -814,6 +757,13 @@ Copyright: Copyright (c) 2019 Jordan Harband Source: git+https://github.com/inspect-js/functions-have-names.git Link: https://github.com/inspect-js/functions-have-names#readme ----------- +generator-function +License: MIT +License File: node_modules/generator-function/LICENSE.md +Copyright: Copyright (c) 2015 Tiancheng “Timothy” Gu +Source: git+https://github.com/TimothyGu/generator-function.git +Link: https://github.com/TimothyGu/generator-function#readme +----------- gensync License: MIT License File: node_modules/gensync/LICENSE @@ -1026,13 +976,6 @@ Copyright: Copyright (c) 2014-present, Lee Byron and other contributors. Source: git://github.com/immutable-js/immutable-js.git Link: https://immutable-js.com ----------- -import-fresh -License: MIT -License File: node_modules/import-fresh/license -Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) -Source: sindresorhus/import-fresh -Link: sindresorhus/import-fresh ------------ import-local License: MIT License File: node_modules/import-local/license @@ -1529,7 +1472,7 @@ License: MIT License File: node_modules/js-yaml/LICENSE Copyright: Copyright (C) 2011-2015 by Vitaly Puzrin Source: nodeca/js-yaml -Link: nodeca/js-yaml +Link: https://github.com/nodeca/js-yaml ----------- jsdom License: MIT @@ -1645,12 +1588,6 @@ License File: node_modules/lodash.memoize/LICENSE Source: lodash/lodash Link: https://lodash.com/ ----------- -lodash.merge -License: MIT -License File: node_modules/lodash.merge/LICENSE -Source: lodash/lodash -Link: https://lodash.com/ ------------ lodash.throttle License: MIT License File: node_modules/lodash.throttle/LICENSE @@ -1741,11 +1678,10 @@ Source: sindresorhus/mimic-fn Link: sindresorhus/mimic-fn ----------- minimatch -License: ISC -License File: node_modules/minimatch/LICENSE -Copyright: Copyright (c) Isaac Z. Schlueter and Contributors -Source: git://github.com/isaacs/minimatch.git -Link: git://github.com/isaacs/minimatch.git +License: BlueOak-1.0.0 +License File: node_modules/minimatch/LICENSE.md +Source: git@github.com:isaacs/minimatch +Link: git@github.com:isaacs/minimatch ----------- minimist License: MIT @@ -1754,9 +1690,8 @@ Source: git://github.com/minimistjs/minimist.git Link: https://github.com/minimistjs/minimist ----------- minipass -License: ISC -License File: node_modules/minipass/LICENSE -Copyright: Copyright (c) 2017-2023 npm, Inc., Isaac Z. Schlueter, and Contributors +License: BlueOak-1.0.0 +License File: node_modules/minipass/LICENSE.md Source: https://github.com/isaacs/minipass Link: https://github.com/isaacs/minipass ----------- @@ -1872,27 +1807,6 @@ Copyright: Copyright (c) 2014 Jordan Harband Source: git://github.com/ljharb/object.assign.git Link: git://github.com/ljharb/object.assign.git ----------- -object.fromentries -License: MIT -License File: node_modules/object.fromentries/LICENSE -Copyright: Copyright (c) 2018 Jordan Harband -Source: git://github.com/es-shims/Object.fromEntries.git -Link: git://github.com/es-shims/Object.fromEntries.git ------------ -object.groupby -License: MIT -License File: node_modules/object.groupby/LICENSE -Copyright: Copyright (c) 2023 ECMAScript Shims -Source: git+https://github.com/es-shims/Object.groupBy.git -Link: https://github.com/es-shims/Object.groupBy#readme ------------ -object.values -License: MIT -License File: node_modules/object.values/LICENSE -Copyright: Copyright (c) 2015 Jordan Harband -Source: git://github.com/es-shims/Object.values.git -Link: git://github.com/es-shims/Object.values.git ------------ once License: ISC License File: node_modules/once/LICENSE @@ -1948,13 +1862,6 @@ License File: node_modules/package-json-from-dist/LICENSE.md Source: git+https://github.com/isaacs/package-json-from-dist.git Link: git+https://github.com/isaacs/package-json-from-dist.git ----------- -parent-module -License: MIT -License File: node_modules/parent-module/license -Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) -Source: sindresorhus/parent-module -Link: sindresorhus/parent-module ------------ parse-json License: MIT License File: node_modules/parse-json/license @@ -2159,8 +2066,8 @@ resolve License: MIT License File: node_modules/resolve/LICENSE Copyright: Copyright (c) 2012 James Halliday -Source: git://github.com/browserify/resolve.git -Link: git://github.com/browserify/resolve.git +Source: ssh://github.com/browserify/resolve.git +Link: ssh://github.com/browserify/resolve.git ----------- rrweb-cssom License: MIT @@ -2405,7 +2312,7 @@ Link: sindresorhus/string-width string-width License: MIT License File: node_modules/string-width/license -Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) Source: sindresorhus/string-width Link: sindresorhus/string-width ----------- @@ -2570,13 +2477,6 @@ Copyright: Copyright (c) 2014 Blake Embrey (*****@***********.***) Source: git://github.com/TypeStrong/ts-node.git Link: https://typestrong.org/ts-node ----------- -tsconfig-paths -License: MIT -License File: node_modules/tsconfig-paths/LICENSE -Copyright: Copyright (c) 2016 Jonas Kello -Source: https://github.com/dividab/tsconfig-paths -Link: https://github.com/dividab/tsconfig-paths ------------ type-check License: MIT License File: node_modules/type-check/LICENSE @@ -2810,7 +2710,7 @@ Link: chalk/wrap-ansi wrap-ansi License: MIT License File: node_modules/wrap-ansi/license -Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) Source: chalk/wrap-ansi Link: chalk/wrap-ansi ----------- @@ -2892,12 +2792,6 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindres Source: sindresorhus/yocto-queue Link: sindresorhus/yocto-queue ----------- -@ampproject/remapping -License: Apache-2.0 -License File: node_modules/@ampproject/remapping/LICENSE -Source: git+https://github.com/ampproject/remapping.git -Link: git+https://github.com/ampproject/remapping.git ------------ @asamuzakjp/css-color License: MIT License File: node_modules/@asamuzakjp/css-color/LICENSE @@ -3172,15 +3066,15 @@ Link: https://demurgos.github.io/v8-coverage License: MIT License File: node_modules/@codemirror/autocomplete/LICENSE Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others -Source: https://github.com/codemirror/autocomplete.git -Link: https://github.com/codemirror/autocomplete.git +Source: git+https://github.com/codemirror/autocomplete.git +Link: git+https://github.com/codemirror/autocomplete.git ----------- @codemirror/commands License: MIT License File: node_modules/@codemirror/commands/LICENSE Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others -Source: https://github.com/codemirror/commands.git -Link: https://github.com/codemirror/commands.git +Source: git+https://github.com/codemirror/commands.git +Link: git+https://github.com/codemirror/commands.git ----------- @codemirror/lang-css License: MIT @@ -3200,8 +3094,8 @@ Link: https://github.com/codemirror/lang-html.git License: MIT License File: node_modules/@codemirror/lang-javascript/LICENSE Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others -Source: https://github.com/codemirror/lang-javascript.git -Link: https://github.com/codemirror/lang-javascript.git +Source: git+https://github.com/codemirror/lang-javascript.git +Link: git+https://github.com/codemirror/lang-javascript.git ----------- @codemirror/lang-json License: MIT @@ -3235,8 +3129,8 @@ Link: https://github.com/codemirror/lang-xml.git License: MIT License File: node_modules/@codemirror/language/LICENSE Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others -Source: https://github.com/codemirror/language.git -Link: https://github.com/codemirror/language.git +Source: git+https://github.com/codemirror/language.git +Link: git+https://github.com/codemirror/language.git ----------- @codemirror/legacy-modes License: MIT @@ -3249,22 +3143,22 @@ Link: https://github.com/codemirror/legacy-modes.git License: MIT License File: node_modules/@codemirror/lint/LICENSE Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others -Source: https://github.com/codemirror/lint.git -Link: https://github.com/codemirror/lint.git +Source: git+https://github.com/codemirror/lint.git +Link: git+https://github.com/codemirror/lint.git ----------- @codemirror/search License: MIT License File: node_modules/@codemirror/search/LICENSE Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others -Source: https://github.com/codemirror/search.git -Link: https://github.com/codemirror/search.git +Source: git+https://github.com/codemirror/search.git +Link: git+https://github.com/codemirror/search.git ----------- @codemirror/state License: MIT License File: node_modules/@codemirror/state/LICENSE Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others -Source: https://github.com/codemirror/state.git -Link: https://github.com/codemirror/state.git +Source: git+https://github.com/codemirror/state.git +Link: git+https://github.com/codemirror/state.git ----------- @codemirror/theme-one-dark License: MIT @@ -3277,8 +3171,8 @@ Link: https://github.com/codemirror/theme-one-dark.git License: MIT License File: node_modules/@codemirror/view/LICENSE Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others -Source: https://github.com/codemirror/view.git -Link: https://github.com/codemirror/view.git +Source: git+https://github.com/codemirror/view.git +Link: git+https://github.com/codemirror/view.git ----------- @cspotcode/source-map-support License: MIT @@ -3358,12 +3252,6 @@ License File: node_modules/@eslint/core/LICENSE Source: git+https://github.com/eslint/rewrite.git Link: https://github.com/eslint/rewrite/tree/main/packages/core#readme ----------- -@eslint/eslintrc -License: MIT -License File: node_modules/@eslint/eslintrc/LICENSE -Source: eslint/eslintrc -Link: https://github.com/eslint/eslintrc#readme ------------ @eslint/js License: MIT License File: node_modules/@eslint/js/LICENSE @@ -3567,6 +3455,13 @@ Copyright: Copyright 2024 Justin Ridgewell <******@*********.****> Source: git+https://github.com/jridgewell/sourcemaps.git Link: https://github.com/jridgewell/sourcemaps/tree/main/packages/gen-mapping ----------- +@jridgewell/remapping +License: MIT +License File: node_modules/@jridgewell/remapping/LICENSE +Copyright: Copyright 2024 Justin Ridgewell <******@*********.****> +Source: git+https://github.com/jridgewell/sourcemaps.git +Link: https://github.com/jridgewell/sourcemaps/tree/main/packages/remapping +----------- @jridgewell/resolve-uri License: MIT License File: node_modules/@jridgewell/resolve-uri/LICENSE @@ -3704,18 +3599,12 @@ License: MIT Source: git+https://github.com/un-ts/pkgr.git Link: https://github.com/un-ts/pkgr/blob/master/packages/core ----------- -@rtsao/scc -License: MIT -License File: node_modules/@rtsao/scc/LICENSE -Copyright: Copyright (c) 2019 Ryan Tsao -Source: rtsao/scc -Link: rtsao/scc ------------ @sinclair/typebox License: MIT License File: node_modules/@sinclair/typebox/license -Source: https://github.com/sinclairzx81/typebox -Link: https://github.com/sinclairzx81/typebox +Copyright: Copyright (c) 2017-2026 Haydn Paterson +Source: https://github.com/sinclairzx81/typebox-legacy +Link: https://github.com/sinclairzx81/typebox-legacy ----------- @sinonjs/commons License: BSD-3-Clause @@ -3798,6 +3687,13 @@ Copyright: Copyright (c) Microsoft Corporation. Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/babel__traverse ----------- +@types/esrecurse +License: MIT +License File: node_modules/@types/esrecurse/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/esrecurse +----------- @types/estree License: MIT License File: node_modules/@types/estree/LICENSE @@ -3847,11 +3743,6 @@ Copyright: Copyright (c) Microsoft Corporation. Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/json-schema ----------- -@types/json5 -License: MIT -Source: https://www.github.com/DefinitelyTyped/DefinitelyTyped.git -Link: https://www.github.com/DefinitelyTyped/DefinitelyTyped.git ------------ @types/linkify-it License: MIT License File: node_modules/@types/linkify-it/LICENSE diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt index 348fce2ff98..2259c44c301 100644 --- a/dev/licensing/php-library-licenses.txt +++ b/dev/licensing/php-library-licenses.txt @@ -109,7 +109,7 @@ firebase/php-jwt License: BSD-3-Clause License File: vendor/firebase/php-jwt/LICENSE Copyright: Copyright (c) 2011, Neuman Vong -Source: https://github.com/firebase/php-jwt.git +Source: https://github.com/googleapis/php-jwt.git Link: https://github.com/firebase/php-jwt ----------- fruitcake/php-cors From f01bb749ab3f11fc465bf5f247dc84e297e4f98a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 30 May 2026 13:45:25 +0100 Subject: [PATCH 259/398] Workflows: Attempted fixing crowdin files Currently causing extra files to be created alongside previous files in crowdin --- .forgejo/workflows/sync-translations.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/sync-translations.yml b/.forgejo/workflows/sync-translations.yml index a0c09ba8f6b..9501c15e133 100644 --- a/.forgejo/workflows/sync-translations.yml +++ b/.forgejo/workflows/sync-translations.yml @@ -22,6 +22,7 @@ jobs: - name: crowdin action uses: https://github.com/crowdin/github-action@v2 with: + crowdin_branch_name: development upload_sources: true upload_translations: false download_translations: true From 5611c8c0da8927cd4831a9dd23a022a2d9bcfa18 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 30 May 2026 21:44:25 +0100 Subject: [PATCH 260/398] Maintenance: Started work on PHPStan Level 5 --- app/Access/ExternalBaseUserProvider.php | 2 +- app/Access/Guards/ExternalBaseSessionGuard.php | 8 ++++++-- app/Access/Guards/LdapSessionGuard.php | 4 ++++ app/Access/Oidc/OidcJwtWithClaims.php | 2 -- app/Activity/Models/Comment.php | 8 +++++++- app/Api/ApiDocsGenerator.php | 4 ++-- app/App/Providers/ThemeServiceProvider.php | 7 ++++++- app/App/helpers.php | 3 ++- app/Entities/Models/Entity.php | 2 +- app/Entities/Queries/QueryPopular.php | 2 ++ app/Entities/Queries/QueryRecentlyViewed.php | 2 ++ app/Entities/Queries/QueryTopFavourites.php | 2 ++ app/Http/Controller.php | 3 ++- app/Util/DatabaseTransaction.php | 3 ++- phpstan.neon.dist | 2 +- 15 files changed, 40 insertions(+), 14 deletions(-) diff --git a/app/Access/ExternalBaseUserProvider.php b/app/Access/ExternalBaseUserProvider.php index 2165fd4591e..2bc15cb0305 100644 --- a/app/Access/ExternalBaseUserProvider.php +++ b/app/Access/ExternalBaseUserProvider.php @@ -42,7 +42,7 @@ public function updateRememberToken(Authenticatable $user, $token) /** * Retrieve a user by the given credentials. */ - public function retrieveByCredentials(array $credentials): ?Authenticatable + public function retrieveByCredentials(array $credentials): ?User { return User::query() ->where('external_auth_id', $credentials['external_auth_id']) diff --git a/app/Access/Guards/ExternalBaseSessionGuard.php b/app/Access/Guards/ExternalBaseSessionGuard.php index 91239599ba9..b7bf174a1de 100644 --- a/app/Access/Guards/ExternalBaseSessionGuard.php +++ b/app/Access/Guards/ExternalBaseSessionGuard.php @@ -50,8 +50,12 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Create a new authentication guard. */ - public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService) - { + public function __construct( + string $name, + UserProvider $provider, + Session $session, + RegistrationService $registrationService + ) { $this->name = $name; $this->session = $session; $this->provider = $provider; diff --git a/app/Access/Guards/LdapSessionGuard.php b/app/Access/Guards/LdapSessionGuard.php index 9455d530dfe..f3628c897d8 100644 --- a/app/Access/Guards/LdapSessionGuard.php +++ b/app/Access/Guards/LdapSessionGuard.php @@ -83,6 +83,10 @@ public function attempt(array $credentials = [], $remember = false): bool } } + if (!($user instanceof User)) { + throw new LoginAttemptException('Could not find or create a user for LDAP login.'); + } + // Sync LDAP groups if required if ($this->ldapService->shouldSyncGroups()) { $this->ldapService->syncGroups($user, $username); diff --git a/app/Access/Oidc/OidcJwtWithClaims.php b/app/Access/Oidc/OidcJwtWithClaims.php index 9d7eeead1a9..9763ab15864 100644 --- a/app/Access/Oidc/OidcJwtWithClaims.php +++ b/app/Access/Oidc/OidcJwtWithClaims.php @@ -131,8 +131,6 @@ protected function validateTokenSignature(): void } }, $this->keys); - $parsedKeys = array_filter($parsedKeys); - $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; /** @var OidcJwtSigningKey $parsedKey */ foreach ($parsedKeys as $parsedKey) { diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 3faa76657b6..b49eb456488 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -3,6 +3,7 @@ namespace BookStack\Activity\Models; use BookStack\App\Model; +use BookStack\Entities\Models\Page; use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\HasCreatorAndUpdater; @@ -40,6 +41,9 @@ class Comment extends Model implements Loggable, OwnableInterface /** * Get the entity that this comment belongs to. + * It's only pages right now hence the typing below. + * Would need a deeper audit if that changes as many areas assume this is always a page. + * @return MorphTo */ public function entity(): MorphTo { @@ -55,7 +59,9 @@ public function entity(): MorphTo // Ultimately, we could just align the method name to 'commentable' but that would be a potential // breaking change and not really worthwhile in a patch due to the risk of creating extra problems. - return $this->morphTo(null, 'commentable_type', 'commentable_id'); + /** @var MorphTo $relation */ + $relation = $this->morphTo(null, 'commentable_type', 'commentable_id'); + return $relation; } /** diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index 53cb2890a7e..8d1be79c88f 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -138,8 +138,8 @@ protected function getValidationAsString($validation): string return $validation; } - if (is_object($validation) && method_exists($validation, '__toString')) { - return strval($validation); + if (is_object($validation) && $validation instanceof \Stringable) { + return $validation->__toString(); } if ($validation instanceof Password) { diff --git a/app/App/Providers/ThemeServiceProvider.php b/app/App/Providers/ThemeServiceProvider.php index 671e5e1df74..9b0f7001e81 100644 --- a/app/App/Providers/ThemeServiceProvider.php +++ b/app/App/Providers/ThemeServiceProvider.php @@ -7,6 +7,7 @@ use BookStack\Theming\ThemeViews; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; +use Illuminate\View\FileViewFinder; class ThemeServiceProvider extends ServiceProvider { @@ -27,7 +28,11 @@ public function boot(): void // Boot up the theme system $themeService = $this->app->make(ThemeService::class); $viewFactory = $this->app->make('view'); - $themeViews = new ThemeViews($viewFactory->getFinder()); + $viewFinder = $viewFactory->getFinder(); + if (!($viewFinder instanceof FileViewFinder)) { + throw new \Exception('Only the file view finder is supported for the theme system'); + } + $themeViews = new ThemeViews($viewFinder); // Use a custom include so that we can insert theme views before/after includes. // This is done, even if no theme is active, so that view caching does not create problems diff --git a/app/App/helpers.php b/app/App/helpers.php index 8f210ecafd4..45a84da8db7 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -6,6 +6,7 @@ use BookStack\Permissions\Permission; use BookStack\Permissions\PermissionApplicator; use BookStack\Settings\SettingService; +use BookStack\Users\Models\OwnableInterface; use BookStack\Users\Models\User; /** @@ -40,7 +41,7 @@ function user(): User * Check if the current user has a permission. If an ownable element * is passed in the jointPermissions are checked against that particular item. */ -function userCan(string|Permission $permission, ?Model $ownable = null): bool +function userCan(string|Permission $permission, (Model&OwnableInterface)|null $ownable = null): bool { if (is_null($ownable)) { return user()->can($permission); diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 27cfccaa836..296bd3a6621 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -46,7 +46,7 @@ * @property int|null $created_by * @property int|null $updated_by * @property int|null $owned_by - * @property Collection $tags + * @property Collection $tags * * @method static Entity|Builder visible() * @method static Builder withLastView() diff --git a/app/Entities/Queries/QueryPopular.php b/app/Entities/Queries/QueryPopular.php index 065ae82ef82..1782070de86 100644 --- a/app/Entities/Queries/QueryPopular.php +++ b/app/Entities/Queries/QueryPopular.php @@ -6,6 +6,7 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Tools\MixedEntityListLoader; use BookStack\Permissions\PermissionApplicator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -20,6 +21,7 @@ public function __construct( public function run(int $count, int $page, array $filterModels): Collection { + /** @var Builder $query */ $query = $this->permissions ->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type') ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) diff --git a/app/Entities/Queries/QueryRecentlyViewed.php b/app/Entities/Queries/QueryRecentlyViewed.php index f28b8f8652f..8ba1a57badd 100644 --- a/app/Entities/Queries/QueryRecentlyViewed.php +++ b/app/Entities/Queries/QueryRecentlyViewed.php @@ -5,6 +5,7 @@ use BookStack\Activity\Models\View; use BookStack\Entities\Tools\MixedEntityListLoader; use BookStack\Permissions\PermissionApplicator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; class QueryRecentlyViewed @@ -22,6 +23,7 @@ public function run(int $count, int $page): Collection return collect(); } + /** @var Builder $query */ $query = $this->permissions->restrictEntityRelationQuery( View::query(), 'views', diff --git a/app/Entities/Queries/QueryTopFavourites.php b/app/Entities/Queries/QueryTopFavourites.php index 6340e35ef18..2719e94bc8b 100644 --- a/app/Entities/Queries/QueryTopFavourites.php +++ b/app/Entities/Queries/QueryTopFavourites.php @@ -5,6 +5,7 @@ use BookStack\Activity\Models\Favourite; use BookStack\Entities\Tools\MixedEntityListLoader; use BookStack\Permissions\PermissionApplicator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\JoinClause; class QueryTopFavourites @@ -22,6 +23,7 @@ public function run(int $count, int $skip = 0) return collect(); } + /** @var Builder $query */ $query = $this->permissions ->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type') ->select('favourites.*') diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 796505795e5..09c38ee6123 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -7,6 +7,7 @@ use BookStack\Exceptions\NotifyException; use BookStack\Facades\Activity; use BookStack\Permissions\Permission; +use BookStack\Users\Models\OwnableInterface; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\JsonResponse; @@ -80,7 +81,7 @@ protected function preventGuestAccess(): void /** * Check the current user's permissions against an ownable item otherwise throw an exception. */ - protected function checkOwnablePermission(string|Permission $permission, Model $ownable, string $redirectLocation = '/'): void + protected function checkOwnablePermission(string|Permission $permission, Model&OwnableInterface $ownable, string $redirectLocation = '/'): void { if (!userCan($permission, $ownable)) { $this->showPermissionError($redirectLocation); diff --git a/app/Util/DatabaseTransaction.php b/app/Util/DatabaseTransaction.php index e36bd2ef310..2e57b6479a1 100644 --- a/app/Util/DatabaseTransaction.php +++ b/app/Util/DatabaseTransaction.php @@ -3,6 +3,7 @@ namespace BookStack\Util; use Closure; +use Illuminate\Database\Connection; use Illuminate\Support\Facades\DB; use Throwable; @@ -24,7 +25,7 @@ class DatabaseTransaction { /** - * @param (Closure(static): TReturn) $callback + * @param (Closure(Connection): TReturn) $callback */ public function __construct( protected Closure $callback diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bab28ea0eb3..54403adfefc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,7 +7,7 @@ parameters: - app # The level 8 is the highest level - level: 4 + level: 5 phpVersion: min: 80200 From 03c9ddd14fed16177ebe59adaef36fd6eb5bded4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 31 May 2026 11:12:23 +0100 Subject: [PATCH 261/398] Maintenance: Further PHPStan level 5 type fixes --- app/Access/Oidc/OidcService.php | 1 + app/Activity/ActivityQueries.php | 3 ++ app/Activity/Tools/WebhookFormatter.php | 8 +++- .../Controllers/PageApiController.php | 20 ++++++---- app/Entities/Controllers/PageController.php | 5 ++- .../Controllers/PageRevisionController.php | 2 +- .../Tools/Markdown/CustomListItemRenderer.php | 6 ++- app/Entities/Tools/PageContent.php | 37 +++++++++++++------ app/Entities/Tools/PageIncludeParser.php | 4 +- app/Entities/Tools/SlugGenerator.php | 2 +- 10 files changed, 59 insertions(+), 29 deletions(-) diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php index a84bd320513..19120e2d3c3 100644 --- a/app/Access/Oidc/OidcService.php +++ b/app/Access/Oidc/OidcService.php @@ -80,6 +80,7 @@ public function processAuthorizeResponse(?string $authorizationCode): User $provider->setPkceCode($pkceCode); // Try to exchange authorization code for access token + /** @var OidcAccessToken $accessToken */ $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $authorizationCode, ]); diff --git a/app/Activity/ActivityQueries.php b/app/Activity/ActivityQueries.php index d5b047937fb..a3383e80933 100644 --- a/app/Activity/ActivityQueries.php +++ b/app/Activity/ActivityQueries.php @@ -11,6 +11,7 @@ use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; @@ -27,6 +28,7 @@ public function __construct( */ public function latest(int $count = 20, int $page = 0): array { + /** @var Collection $activityList */ $activityList = $this->permissions ->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type') ->orderBy('created_at', 'desc') @@ -83,6 +85,7 @@ public function entityActivity(Entity $entity, int $count = 20, int $page = 1): */ public function userActivity(User $user, int $count = 20, int $page = 0): array { + /** @var Collection $activityList */ $activityList = $this->permissions ->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type') ->orderBy('created_at', 'desc') diff --git a/app/Activity/Tools/WebhookFormatter.php b/app/Activity/Tools/WebhookFormatter.php index cb4e9cb0a9c..111efd5d498 100644 --- a/app/Activity/Tools/WebhookFormatter.php +++ b/app/Activity/Tools/WebhookFormatter.php @@ -73,13 +73,17 @@ public function addDefaultModelFormatters(): void // Load entity owner, creator, updater details $this->addModelFormatter( fn ($event, $model) => ($model instanceof Entity), - fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy']) + function ($model) { + $model->load(['ownedBy', 'createdBy', 'updatedBy']); + } ); // Load revision detail for page update and create events $this->addModelFormatter( fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)), - fn ($model) => $model->load('currentRevision') + function ($model) { + $model->load('currentRevision'); + } ); } diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 38042e67058..ca8989f0764 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -1,5 +1,7 @@ queries->visibleForList() ->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']); @@ -69,7 +73,7 @@ public function list() * Any images included via base64 data URIs will be extracted and saved as gallery * images against the page during upload. */ - public function create(Request $request) + public function create(Request $request): JsonResponse { $this->validate($request, $this->rules['create']); @@ -102,9 +106,9 @@ public function create(Request $request) * Comments for the page are provided in a tree-structure representing the hierarchy of top-level * comments and replies, for both archived and active comments. */ - public function read(string $id) + public function read(string $id): JsonResponse { - $page = $this->queries->findVisibleByIdOrFail($id); + $page = $this->queries->findVisibleByIdOrFail(intval($id)); $page = $page->forJsonDisplay(); $commentTree = (new CommentTree($page)); @@ -124,11 +128,11 @@ public function read(string $id) * Providing a 'book_id' or 'chapter_id' property will essentially move * the page into that parent element if you have permissions to do so. */ - public function update(Request $request, string $id) + public function update(Request $request, string $id): JsonResponse { $requestData = $this->validate($request, $this->rules['update']); - $page = $this->queries->findVisibleByIdOrFail($id); + $page = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::PageUpdate, $page); $parent = null; @@ -161,9 +165,9 @@ public function update(Request $request, string $id) * Delete a page. * This will typically send the page to the recycle bin. */ - public function delete(string $id) + public function delete(string $id): Response { - $page = $this->queries->findVisibleByIdOrFail($id); + $page = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::PageDelete, $page); $this->pageRepo->destroy($page); diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index 82edfbc2763..dd655e7abb3 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -25,6 +25,7 @@ use BookStack\Util\HtmlContentFilterConfig; use Exception; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Throwable; @@ -358,8 +359,8 @@ public function destroyDraft(string $bookSlug, int $pageId) */ public function showRecentlyUpdated() { - $visibleBelongsScope = function (BelongsTo $query) { - $query->scopes('visible'); + $visibleBelongsScope = function (Relation $relation): void { + $relation->scopes('visible'); }; $pages = $this->queries->visibleForList() diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index cc6b79bfe45..801aeed3ed3 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -177,7 +177,7 @@ public function destroy(string $bookSlug, string $pageSlug, int $revId) */ public function destroyUserDraft(string $pageId) { - $page = $this->pageQueries->findVisibleByIdOrFail($pageId); + $page = $this->pageQueries->findVisibleByIdOrFail(intval($pageId)); $this->revisionRepo->deleteDraftsForCurrentUser($page); return response('', 200); diff --git a/app/Entities/Tools/Markdown/CustomListItemRenderer.php b/app/Entities/Tools/Markdown/CustomListItemRenderer.php index 0c506d7f9b5..14cdaa2eaa4 100644 --- a/app/Entities/Tools/Markdown/CustomListItemRenderer.php +++ b/app/Entities/Tools/Markdown/CustomListItemRenderer.php @@ -25,9 +25,13 @@ public function __construct() */ public function render(Node $node, ChildNodeRendererInterface $childRenderer) { + if (!($node instanceof ListItem)) { + return null; + } + $listItem = $this->baseRenderer->render($node, $childRenderer); - if ($node instanceof ListItem && $this->startsTaskListItem($node) && $listItem instanceof HtmlElement) { + if ($this->startsTaskListItem($node) && $listItem instanceof HtmlElement) { $listItem->setAttribute('class', 'task-list-item'); } diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 9fb4596f5a0..c66dcbe4151 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -421,28 +421,41 @@ public function getNavigation(string $htmlContent): array */ protected function headerNodesToLevelList(DOMNodeList $nodeList): array { - $tree = collect($nodeList)->map(function (DOMElement $header) { + $minLevel = 6; + + $headerDetails = array_map(function (DOMNode $header) use (&$minLevel) { + if (!$header instanceof DOMElement) { + return null; + } + $text = trim(str_replace("\xc2\xa0", ' ', $header->nodeValue)); $text = mb_substr($text, 0, 100); + if (empty($text)) { + return null; + } + + $level = intval(str_replace('h', '', $header->nodeName)); + if ($level < $minLevel) { + $minLevel = $level; + } + return [ 'nodeName' => strtolower($header->nodeName), - 'level' => intval(str_replace('h', '', $header->nodeName)), + 'level' => $level, 'link' => '#' . $header->getAttribute('id'), 'text' => $text, ]; - })->filter(function ($header) { - return mb_strlen($header['text']) > 0; - }); + }, [...$nodeList]); - // Shift headers if only smaller headers have been used - $levelChange = ($tree->pluck('level')->min() - 1); - $tree = $tree->map(function ($header) use ($levelChange) { - $header['level'] -= ($levelChange); + $filtered = array_values(array_filter($headerDetails)); - return $header; - }); + // Shift headers if only smaller headers have been used + $levelChange = ($minLevel - 1); + foreach ($filtered as $index => $header) { + $filtered[$index]['level'] -= $levelChange; + } - return $tree->toArray(); + return $filtered; } } diff --git a/app/Entities/Tools/PageIncludeParser.php b/app/Entities/Tools/PageIncludeParser.php index af7ed4fc6a1..16917f41c8e 100644 --- a/app/Entities/Tools/PageIncludeParser.php +++ b/app/Entities/Tools/PageIncludeParser.php @@ -192,10 +192,10 @@ protected function splitNodeAtChildNode(DOMElement $parentNode, DOMNode $domNode /** * Get the parent paragraph of the given node, if existing. */ - protected function getParentParagraph(DOMNode $parent): ?DOMNode + protected function getParentParagraph(DOMNode $parent): ?DOMElement { do { - if (strtolower($parent->nodeName) === 'p') { + if (strtolower($parent->nodeName) === 'p' && $parent instanceof DOMElement) { return $parent; } diff --git a/app/Entities/Tools/SlugGenerator.php b/app/Entities/Tools/SlugGenerator.php index 6eec84a91c1..ac003b97a8f 100644 --- a/app/Entities/Tools/SlugGenerator.php +++ b/app/Entities/Tools/SlugGenerator.php @@ -52,7 +52,7 @@ protected function formatNameAsSlug(string $name): string { $slug = Str::slug($name); if ($slug === '') { - $slug = substr(md5(rand(1, 500)), 0, 5); + $slug = substr(md5(strval(rand(1, 500))), 0, 5); } return $slug; From fa3b7f77b9d430304ac5683a0db374adca2f85d3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 31 May 2026 15:42:54 +0100 Subject: [PATCH 262/398] Maintenance: Addressed all phpstan level 5 issues --- app/Entities/Models/Book.php | 8 ++++---- app/Entities/Models/Page.php | 4 ++-- app/Exports/ImportRepo.php | 5 +++++ app/Exports/ZipExports/ZipImportRunner.php | 2 +- app/Permissions/JointPermissionBuilder.php | 3 +++ app/References/Reference.php | 2 +- app/References/ReferenceFetcher.php | 4 ++++ app/Search/SearchRunner.php | 4 ++++ app/Settings/SettingService.php | 6 +++--- app/Sorting/SortRule.php | 14 ++++++++++++++ app/Uploads/AttachmentService.php | 2 +- app/Users/Controllers/RoleApiController.php | 2 +- app/Users/Controllers/RoleController.php | 6 +++--- app/Users/Controllers/UserApiController.php | 8 ++++---- 14 files changed, 50 insertions(+), 20 deletions(-) diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 10f04695a5e..abb081d712f 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -20,10 +20,10 @@ * @property ?int $image_id * @property ?int $default_template_id * @property ?int $sort_rule_id - * @property \Illuminate\Database\Eloquent\Collection $chapters - * @property \Illuminate\Database\Eloquent\Collection $pages - * @property \Illuminate\Database\Eloquent\Collection $directPages - * @property \Illuminate\Database\Eloquent\Collection $shelves + * @property \Illuminate\Database\Eloquent\Collection $chapters + * @property \Illuminate\Database\Eloquent\Collection $pages + * @property \Illuminate\Database\Eloquent\Collection $directPages + * @property \Illuminate\Database\Eloquent\Collection $shelves * @property ?SortRule $sortRule */ class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index d3a392da6fa..c38f33af3ca 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -24,8 +24,8 @@ * @property int $revision_count * @property string $editor * @property Chapter|null $chapter - * @property Collection $attachments - * @property Collection $revisions + * @property Collection $attachments + * @property Collection $revisions * @property PageRevision $currentRevision */ class Page extends BookChild diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index 79db69fca8f..ed631409be3 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -3,6 +3,8 @@ namespace BookStack\Exports; use BookStack\Activity\ActivityType; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\FileUploadException; @@ -119,6 +121,9 @@ public function runImport(Import $import, ?string $parent = null): Entity $parentModel = null; if ($import->type === 'page' || $import->type === 'chapter') { $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null; + if ($parentModel && !($parentModel instanceof Book || $parentModel instanceof Chapter)) { + throw new ZipImportException(['Selected parent is not a book or chapter']); + } } DB::beginTransaction(); diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 9fa7dec3afe..37f6c0b8d33 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -48,7 +48,7 @@ public function __construct( * Returns the top-level entity item which was imported. * @throws ZipImportException */ - public function run(Import $import, ?Entity $parent = null): Entity + public function run(Import $import, Book|Chapter|null $parent = null): Entity { $zipPath = $this->getZipPath($import); $reader = new ZipExportReader($zipPath); diff --git a/app/Permissions/JointPermissionBuilder.php b/app/Permissions/JointPermissionBuilder.php index 94f18916d4a..f2159375df1 100644 --- a/app/Permissions/JointPermissionBuilder.php +++ b/app/Permissions/JointPermissionBuilder.php @@ -101,6 +101,7 @@ public function rebuildForRole(Role $role) /** * Get a query for fetching a book with its children. + * @return Builder */ protected function bookFetchQuery(): Builder { @@ -117,9 +118,11 @@ protected function bookFetchQuery(): Builder /** * Build joint permissions for the given book and role combinations. + * @param EloquentCollection $books */ protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false): void { + /** @var EloquentCollection $entities */ $entities = clone $books; /** @var Book $book */ diff --git a/app/References/Reference.php b/app/References/Reference.php index df8a3a78932..602e19eab59 100644 --- a/app/References/Reference.php +++ b/app/References/Reference.php @@ -2,8 +2,8 @@ namespace BookStack\References; +use BookStack\App\Model; use BookStack\Permissions\Models\JointPermission; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php index 8588c6e2c8e..0c76b46982c 100644 --- a/app/References/ReferenceFetcher.php +++ b/app/References/ReferenceFetcher.php @@ -22,6 +22,7 @@ public function __construct( */ public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection { + /** @var Collection $references */ $references = $this->queryReferencesToEntity($entity)->get(); $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents); @@ -37,6 +38,9 @@ public function getReferenceCountToEntity(Entity $entity): int return $this->queryReferencesToEntity($entity)->count(); } + /** + * @return Builder + */ protected function queryReferencesToEntity(Entity $entity): Builder { $baseQuery = Reference::query() diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 3912541723f..c2f3b96c9e6 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -4,6 +4,7 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\EntityTable; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Tools\EntityHydrator; use BookStack\Permissions\PermissionApplicator; @@ -90,6 +91,8 @@ public function searchChapter(int $chapterId, string $searchString): Collection /** * Get a page of result data from the given query based on the provided page parameters. + * @param EloquentBuilder $query + * @return Collection */ protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection { @@ -106,6 +109,7 @@ protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int /** * Create a search query for an entity. * @param string[] $entityTypes + * @return EloquentBuilder */ protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder { diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php index e0b13618012..d35f7711795 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -243,9 +243,9 @@ public function putForCurrentUser(string $key, string $value): bool /** * Convert a setting key into a user-specific key. */ - protected function userKey(string $userId, string $key = ''): string + protected function userKey(int $userId, string $key = ''): string { - return 'user:' . $userId . ':' . $key; + return 'user:' . strval($userId) . ':' . $key; } /** @@ -267,7 +267,7 @@ public function remove(string $key): void /** * Delete settings for a given user id. */ - public function deleteUserSettings(string $userId): void + public function deleteUserSettings(int $userId): void { Setting::query() ->where('setting_key', 'like', $this->userKey($userId) . '%') diff --git a/app/Sorting/SortRule.php b/app/Sorting/SortRule.php index bf53365a201..ffe2d411534 100644 --- a/app/Sorting/SortRule.php +++ b/app/Sorting/SortRule.php @@ -38,21 +38,35 @@ public function setOperations(array $options): void $this->sequence = implode(',', $values); } + /** + * @inheritDoc + */ public function logDescriptor(): string { return "({$this->id}) {$this->name}"; } + /** + * Get the URL to where this rule can be managed. + */ public function getUrl(): string { return url("/settings/sorting/rules/{$this->id}"); } + /** + * Get the books which are specifically set to use this sort rule. + * @return HasMany + */ public function books(): HasMany { return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id'); } + /** + * Get all the available sort rules, ordered by name, with the number of books using each. + * @return Collection + */ public static function allByName(): Collection { return static::query() diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index dabd537292f..3de84bdd0a8 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -102,7 +102,7 @@ public function saveNewFromLink(string $name, string $link, int $page_id): Attac /** * Updates the ordering for a listing of attached files. */ - public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId) + public function updateFileOrderWithinPage(array $attachmentOrder, int $pageId) { foreach ($attachmentOrder as $index => $attachmentId) { Attachment::query()->where('uploaded_to', '=', $pageId) diff --git a/app/Users/Controllers/RoleApiController.php b/app/Users/Controllers/RoleApiController.php index 93ecc549bb4..6f0c59651a5 100644 --- a/app/Users/Controllers/RoleApiController.php +++ b/app/Users/Controllers/RoleApiController.php @@ -87,7 +87,7 @@ public function create(Request $request) */ public function read(string $id) { - $role = $this->permissionsRepo->getRoleById($id); + $role = $this->permissionsRepo->getRoleById(intval($id)); $this->singleFormatter($role); return response()->json($role); diff --git a/app/Users/Controllers/RoleController.php b/app/Users/Controllers/RoleController.php index b9f06dace84..56a6c7416ca 100644 --- a/app/Users/Controllers/RoleController.php +++ b/app/Users/Controllers/RoleController.php @@ -94,7 +94,7 @@ public function store(Request $request) public function edit(string $id) { $this->checkPermission(Permission::UserRolesManage); - $role = $this->permissionsRepo->getRoleById($id); + $role = $this->permissionsRepo->getRoleById(intval($id)); $this->setPageTitle(trans('settings.role_edit')); @@ -129,7 +129,7 @@ public function update(Request $request, string $id) public function showDelete(string $id) { $this->checkPermission(Permission::UserRolesManage); - $role = $this->permissionsRepo->getRoleById($id); + $role = $this->permissionsRepo->getRoleById(intval($id)); $roles = $this->permissionsRepo->getAllRolesExcept($role); $blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]); $roles->prepend($blankRole); @@ -151,7 +151,7 @@ public function delete(Request $request, string $id) try { $migrateRoleId = intval($request->input('migrate_role_id') ?: "0"); - $this->permissionsRepo->deleteRole($id, $migrateRoleId); + $this->permissionsRepo->deleteRole(intval($id), $migrateRoleId); } catch (PermissionsException $e) { $this->showErrorNotification($e->getMessage()); diff --git a/app/Users/Controllers/UserApiController.php b/app/Users/Controllers/UserApiController.php index ebc17e262f3..07eefadc7b1 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -110,7 +110,7 @@ public function create(Request $request) */ public function read(string $id) { - $user = $this->userRepo->getById($id); + $user = $this->userRepo->getById(intval($id)); $this->singleFormatter($user); return response()->json($user); @@ -124,8 +124,8 @@ public function read(string $id) */ public function update(Request $request, string $id) { - $data = $this->validate($request, $this->rules($id)['update']); - $user = $this->userRepo->getById($id); + $data = $this->validate($request, $this->rules(intval($id))['update']); + $user = $this->userRepo->getById(intval($id)); $this->userRepo->update($user, $data, userCan(Permission::UsersManage)); $this->singleFormatter($user); @@ -140,7 +140,7 @@ public function update(Request $request, string $id) */ public function delete(Request $request, string $id) { - $user = $this->userRepo->getById($id); + $user = $this->userRepo->getById(intval($id)); $newOwnerId = $request->input('migrate_ownership_id', null); $this->userRepo->destroy($user, $newOwnerId); From 37f2d05118fa98ee9cb68b8a51c6ae90612de813 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 6 Jun 2026 10:37:06 +0100 Subject: [PATCH 263/398] Search: Prevented tag search using unusable numbers These would trigger an error on use, and could be abused to fill logs. Added test to cover. Thanks to Stephen O. / Sakusen for reporting. --- app/Search/SearchRunner.php | 2 +- tests/Search/EntitySearchTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 3912541723f..d49cb439a6b 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -290,7 +290,7 @@ protected function applyTagSearch(EloquentBuilder $query, TagSearchOption $optio $query->where('name', '=', $tagParts['name']); } - if (is_numeric($tagParts['value']) && $tagParts['operator'] !== 'like') { + if (is_numeric($tagParts['value']) && is_finite($tagParts['value']) && $tagParts['operator'] !== 'like') { // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will // search the value as a string which prevents being able to do number-based operations // on the tag values. We ensure it has a numeric value and then cast it just to be sure. diff --git a/tests/Search/EntitySearchTest.php b/tests/Search/EntitySearchTest.php index fc300241bcf..d29c44eda3d 100644 --- a/tests/Search/EntitySearchTest.php +++ b/tests/Search/EntitySearchTest.php @@ -233,6 +233,18 @@ public function test_search_filters() $this->get('/search?term=' . urlencode('danzorbhsing {created_before:2037-01-01}'))->assertDontSee($page->name); } + public function test_search_tags_with_unexpected_numeric_values_does_not_cause_error() + { + $pageA = $this->entities->page(); + $pageA->name = 'MyTestPageWithAwkwardNumericTagValue'; + $pageA->save(); + $pageA->tags()->save(new Tag(['name' => 'Count', 'value' => '1E999'])); + + $resp = $this->asEditor()->get('/search?term=' . urlencode('[Count=1E999]')); + $resp->assertStatus(200); + $resp->assertSee('MyTestPageWithAwkwardNumericTagValue'); + } + public function test_entity_selector_search() { $page = $this->entities->newPage(['name' => 'my ajax search test', 'html' => 'ajax test']); From 81f77a95de4b060fa77a5376198a80a24cfcca59 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 6 Jun 2026 15:21:15 +0100 Subject: [PATCH 264/398] Content Filtering: Limited file protocol to just anchor hrefs Updated allow list/purifier system to only allow file protocol use on anchor hrefs to avoid potential security concerns with, after export, content being auto loaded via interactive elements like embeds/objects/videos etc... Updated tests to cover. Thanks to Gurmandeep Deol at Seneca Polytechnic for reporting. --- app/Util/HtmlContentFilter.php | 1 + .../ConfiguredHtmlPurifier.php | 19 ++++++- .../Filters/UriLimitFileProtocolToAnchors.php | 55 +++++++++++++++++++ tests/Entity/PageContentFilteringTest.php | 7 +++ 4 files changed, 79 insertions(+), 3 deletions(-) rename app/Util/{ => HtmlPurifier}/ConfiguredHtmlPurifier.php (86%) create mode 100644 app/Util/HtmlPurifier/Filters/UriLimitFileProtocolToAnchors.php diff --git a/app/Util/HtmlContentFilter.php b/app/Util/HtmlContentFilter.php index 2ef797d1304..26f22e6ca6a 100644 --- a/app/Util/HtmlContentFilter.php +++ b/app/Util/HtmlContentFilter.php @@ -2,6 +2,7 @@ namespace BookStack\Util; +use BookStack\Util\HtmlPurifier\ConfiguredHtmlPurifier; use DOMAttr; use DOMElement; use DOMNodeList; diff --git a/app/Util/ConfiguredHtmlPurifier.php b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php similarity index 86% rename from app/Util/ConfiguredHtmlPurifier.php rename to app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php index 1f2528e7155..307cdb74f28 100644 --- a/app/Util/ConfiguredHtmlPurifier.php +++ b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php @@ -1,13 +1,15 @@ getDefinition('HTML', true, true); if ($htmlDef instanceof HTMLPurifier_HTMLDefinition) { - $this->configureDefinition($htmlDef); + $this->configureHtmlDefinition($htmlDef); + } + + /** @var \HTMLPurifier_URIDefinition $uriDef */ + $uriDef = $config->getDefinition('URI', true, true); + if ($uriDef instanceof HTMLPurifier_URIDefinition) { + $this->configureUriDefinition($uriDef); } $this->purifier = new HTMLPurifier($config); @@ -91,7 +99,7 @@ protected function setConfig(HTMLPurifier_Config $config, string $cachePath): vo // $config->set('Cache.DefinitionImpl', null); // Disable cache during testing } - public function configureDefinition(HTMLPurifier_HTMLDefinition $definition): void + protected function configureHtmlDefinition(HTMLPurifier_HTMLDefinition $definition): void { // Allow the object element $definition->addElement( @@ -151,6 +159,11 @@ public function configureDefinition(HTMLPurifier_HTMLDefinition $definition): vo $definition->addAttribute('a', 'data-mention-user-id', 'Number'); } + protected function configureUriDefinition(HTMLPurifier_URIDefinition $definition): void + { + $definition->registerFilter(new UriLimitFileProtocolToAnchors()); + } + public function purify(string $html): string { return $this->purifier->purify($html); diff --git a/app/Util/HtmlPurifier/Filters/UriLimitFileProtocolToAnchors.php b/app/Util/HtmlPurifier/Filters/UriLimitFileProtocolToAnchors.php new file mode 100644 index 00000000000..19ca9cc82b9 --- /dev/null +++ b/app/Util/HtmlPurifier/Filters/UriLimitFileProtocolToAnchors.php @@ -0,0 +1,55 @@ +scheme !== 'file') { + return true; + } + + $token = $context->get('CurrentToken', true); + $attr = $context->get('CurrentAttr', true); + + // Only allow if used on hrefs on anchor tags + $isAnchor = $token && $token->name === 'a'; + $isHref = $attr === 'href'; + if ($isAnchor && $isHref) { + return true; + } + + return false; + } +} + +// vim: et sw=4 sts=4 diff --git a/tests/Entity/PageContentFilteringTest.php b/tests/Entity/PageContentFilteringTest.php index 449189a898c..0ebbd9a0eff 100644 --- a/tests/Entity/PageContentFilteringTest.php +++ b/tests/Entity/PageContentFilteringTest.php @@ -464,6 +464,11 @@ public function test_allow_list_style_filtering() '
    Hello!
    ' => '
    Hello!
    ', '
    Hello!
    ' => '
    Hello!
    ', '
    Hello!
    ' => '
    Hello!
    ', + '' => '', + '' => '', + '' => '', + '
    My local image
    ' => '
    ', + '
    My local image
    ' => '
    ', ]; config()->set('app.content_filtering', 'a'); @@ -476,6 +481,7 @@ public function test_allow_list_style_filtering() $resp = $this->get($page->getUrl()); $resp->assertSee($expected, false); + $resp->assertDontSee($input, false); } } @@ -484,6 +490,7 @@ public function test_allow_list_does_not_filter_cases() $testCasesExpectedByInput = [ '

    New tab linkydoodle

    ', '

    @mentionusertext

    ', + '

    Link to file

    ', '
    Hello

    Mydetailshere

    ', ]; From b7325fdf0efde6f863beed67cde488641a05ef83 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 7 Jun 2026 10:22:52 +0100 Subject: [PATCH 265/398] Attachments: Moved perm checks before validation Avoids providing responses with potential sensitive attachment info before permission checks. Added tests to cover. Thanks to Rafael Castilho for reporting. --- .../Controllers/AttachmentController.php | 16 ++++------ tests/Uploads/AttachmentTest.php | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index 12f49dffee5..bdd96d6ad7b 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -107,6 +107,9 @@ public function update(Request $request, string $attachmentId) { /** @var Attachment $attachment */ $attachment = Attachment::query()->findOrFail($attachmentId); + $this->checkOwnablePermission(Permission::PageView, $attachment->page); + $this->checkOwnablePermission(Permission::PageUpdate, $attachment->page); + $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment); try { $this->validate($request, [ @@ -120,10 +123,6 @@ public function update(Request $request, string $attachmentId) ]), 422); } - $this->checkOwnablePermission(Permission::PageView, $attachment->page); - $this->checkOwnablePermission(Permission::PageUpdate, $attachment->page); - $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment); - $attachment = $this->attachmentService->updateFile($attachment, [ 'name' => $request->input('attachment_edit_name'), 'link' => $request->input('attachment_edit_url'), @@ -142,6 +141,10 @@ public function update(Request $request, string $attachmentId) public function attachLink(Request $request) { $pageId = $request->input('attachment_link_uploaded_to'); + $page = $this->pageQueries->findVisibleByIdOrFail($pageId); + + $this->checkPermission(Permission::AttachmentCreateAll); + $this->checkOwnablePermission(Permission::PageUpdate, $page); try { $this->validate($request, [ @@ -156,11 +159,6 @@ public function attachLink(Request $request) ]), 422); } - $page = $this->pageQueries->findVisibleByIdOrFail($pageId); - - $this->checkPermission(Permission::AttachmentCreateAll); - $this->checkOwnablePermission(Permission::PageUpdate, $page); - $attachmentName = $request->input('attachment_link_name'); $link = $request->input('attachment_link_url'); $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId)); diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 2d402c34006..22e4f4610a0 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -159,6 +159,38 @@ public function test_attachment_updating() $this->files->deleteAllAttachmentFiles(); } + public function test_attachment_update_without_permission() + { + $page = $this->entities->page(); + $attachment = Attachment::factory()->create(['uploaded_to' => $page->id]); + + $this->permissions->disableEntityInheritedPermissions($page); + + $resp = $this->asViewer()->put("attachments/{$attachment->id}", [ + 'attachment_edit_name' => 'My new attachment name', + 'attachment_edit_url' => 'https://test.example.com', + ]); + + $this->assertPermissionError($resp); + } + + public function test_attachment_update_without_permission_with_validation_errors() + { + $page = $this->entities->page(); + /** @var Attachment $attachment */ + $attachment = Attachment::factory()->create(['uploaded_to' => $page->id]); + + $this->permissions->disableEntityInheritedPermissions($page); + + $resp = $this->asViewer()->put("attachments/{$attachment->id}", [ + 'attachment_edit_name' => '', + 'attachment_edit_url' => 'https://test.example.com', + ]); + + $this->assertPermissionError($resp); + $resp->assertDontSee($attachment->path); + } + public function test_file_deletion() { $page = $this->entities->page(); From 84a23fb23f8d15935521ad4f3fdc335be6d90744 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 7 Jun 2026 10:45:40 +0100 Subject: [PATCH 266/398] Logs: Prevented NotifyExceptions for reporting to error logs This is to reduce the amount of content which will be logged, since these messages don't really indicate an actual system error but advise the user of something which went wrong with their request. --- app/Exceptions/Handler.php | 1 + app/Exceptions/NotifyException.php | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 08d326ad81c..97a2b0a4f24 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -25,6 +25,7 @@ class Handler extends ExceptionHandler protected $dontReport = [ NotFoundException::class, StoppedAuthenticationException::class, + NotifyException::class, ]; /** diff --git a/app/Exceptions/NotifyException.php b/app/Exceptions/NotifyException.php index b62b8fde646..d2fba30c4e9 100644 --- a/app/Exceptions/NotifyException.php +++ b/app/Exceptions/NotifyException.php @@ -6,18 +6,22 @@ use Illuminate\Contracts\Support\Responsable; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +/** + * An exception that is thrown to notify the user of something which went wrong. + * Typically these should be translated messages since they will be shown to the end user + * via a pop up notification error message in the UI. + * + * This exception is not intended to be used for internal system/application errors, + * and therefore will not be logged by the exception handler. + */ class NotifyException extends Exception implements Responsable, HttpExceptionInterface { - public $message; - public string $redirectLocation; - protected int $status; - - public function __construct(string $message, string $redirectLocation = '/', int $status = 500) - { + public function __construct( + string $message, + public string $redirectLocation = '/', + protected int $status = 500 + ) { $this->message = $message; - $this->redirectLocation = $redirectLocation; - $this->status = $status; - parent::__construct(); } From 9fc46f76f687581e2ee9a54d097320214c0656cf Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 8 Jun 2026 04:30:58 +0000 Subject: [PATCH 267/398] New Crowdin translations by GitHub Action --- lang/cs/activities.php | 4 ++-- lang/cs/auth.php | 2 +- lang/cs/entities.php | 8 ++++---- lang/cs/settings.php | 10 +++++----- lang/da/auth.php | 2 +- lang/da/entities.php | 6 +++--- lang/da/settings.php | 2 +- lang/de/auth.php | 2 +- lang/de/entities.php | 8 ++++---- lang/de/settings.php | 4 ++-- lang/de_informal/auth.php | 2 +- lang/de_informal/entities.php | 8 ++++---- lang/de_informal/settings.php | 6 +++--- lang/hu/settings.php | 2 +- lang/ja/entities.php | 2 +- lang/ja/settings.php | 2 +- lang/nl/entities.php | 2 +- lang/pt/entities.php | 4 ++-- lang/pt/errors.php | 4 ++-- lang/uk/activities.php | 4 ++-- lang/uk/auth.php | 2 +- lang/uk/entities.php | 8 ++++---- lang/uk/errors.php | 2 +- lang/uk/settings.php | 10 +++++----- 24 files changed, 53 insertions(+), 53 deletions(-) diff --git a/lang/cs/activities.php b/lang/cs/activities.php index 3e497ecedbe..65840f9376a 100644 --- a/lang/cs/activities.php +++ b/lang/cs/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Uživatel byl úspěšně aktualizován', 'user_delete' => 'odstranil uživatele', 'user_delete_notification' => 'Uživatel byl úspěšně odstraněn', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'obnovit vícefaktorové ověření pro uživatele', + 'user_mfa_reset_notification' => 'Obnovení metod vícefaktorového ověřování', // API Tokens 'api_token_create' => 'API token byl vytvořen', diff --git a/lang/cs/auth.php b/lang/cs/auth.php index 4ad62ff1ae6..25a9e48855a 100644 --- a/lang/cs/auth.php +++ b/lang/cs/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Neplatné přihlašovací údaje.', 'throttle' => 'Příliš mnoho pokusů o přihlášení. Zkuste to prosím znovu za :seconds sekund.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => '{0}Příliš mnoho pokusů o vícefázové ověření. Zkuste to prosím znovu za :seconds sekund.|{1}Příliš mnoho pokusů o vícefázové ověření. Zkuste to prosím znovu za :seconds sekundu.|[2,4]Příliš mnoho pokusů o vícefázové ověření. Zkuste to prosím znovu za :seconds sekundy.|[5,*]Příliš mnoho pokusů o vícefázové ověření. Zkuste to prosím znovu za :seconds sekund.', // Login & Register 'sign_up' => 'Registrace', diff --git a/lang/cs/entities.php b/lang/cs/entities.php index e44dcf84e62..9d8a76e9a0e 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => 'Pravidla řazení mohou být vytvořena v nastavení "Seznamy a řazení" uživatelem s příslušnými oprávněními.', 'books_sort_named' => 'Seřadit knihu :bookName', 'books_sort_name' => 'Seřadit podle názvu', 'books_sort_created' => 'Seřadit podle data vytvoření', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Skrýt/Zobrazit postranní panel', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Obsah stránky', + 'page_contents_none' => 'Na stránce nejsou žádné nadpisy.', + 'page_contents_info' => 'Obsah se generuje ze všech použitých nadpisů na stránce.', 'page_tags' => 'Štítky stránky', 'chapter_tags' => 'Štítky kapitoly', 'book_tags' => 'Štítky knihy', diff --git a/lang/cs/settings.php b/lang/cs/settings.php index c11b7ee586e..85c479dbddb 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -207,7 +207,7 @@ '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_controlled_by_page_delete' => 'Řídí se právem k odstranění stránky', 'role_save' => 'Uložit roli', 'role_users' => 'Uživatelé mající tuto roli', 'role_users_none' => 'Žádný uživatel nemá tuto roli', @@ -260,13 +260,13 @@ 'users_api_tokens_create' => 'Vytvořit Token', 'users_api_tokens_expires' => 'Vyprší', 'users_api_tokens_docs' => 'Dokumentace API', - 'users_mfa' => 'Vícefázové ověření', + 'users_mfa' => 'Vícefaktorové ověření', 'users_mfa_desc' => 'Nastavit vícefaktorové ověřování jako další vrstvu zabezpečení vašeho uživatelského účtu.', 'users_mfa_x_methods' => ':count nastavená metoda|:count nastavených metod', 'users_mfa_configure' => 'Konfigurovat metody', - '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?', + 'users_mfa_reset' => 'Obnova metod vícefaktorového ověření', + 'users_mfa_reset_desc' => 'Tímto se obnoví a vymažou všechny nakonfigurované metody vícefaktorového ověřování pro tohoto uživatele. Pokud některá z jeho rolí vyžaduje vícefaktorové ověřování, bude při příštím přihlášení vyzván k nastavení nových metod.', + 'users_mfa_reset_confirm' => 'Opravdu chcete obnovit vícefaktorové ověřování pro tohoto uživatele?', // API Tokens 'user_api_token_create' => 'Vytvořit API Token', diff --git a/lang/da/auth.php b/lang/da/auth.php index 95ebd6230e6..471be2a114a 100644 --- a/lang/da/auth.php +++ b/lang/da/auth.php @@ -8,7 +8,7 @@ 'failed' => 'De indtastede brugeroplysninger stemmer ikke overens med vores registreringer.', 'throttle' => 'For mange mislykkede loginforsøg. Prøv igen om :seconds sekunder.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'For mange mislykkede loginforsøg. Prøv igen om :seconds sekunder.', // Login & Register 'sign_up' => 'Registrer', diff --git a/lang/da/entities.php b/lang/da/entities.php index f1c5735eddf..a078ad9d85f 100644 --- a/lang/da/entities.php +++ b/lang/da/entities.php @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Sidebjælke til/fra', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Sideindhold', + 'page_contents_none' => 'Ingen overskrifter blev fundet i sidens indhold.', + 'page_contents_info' => 'Indholdsmenuen er genereret fra alle kursformater, der bruges på siden.', 'page_tags' => 'Sidetags', 'chapter_tags' => 'Kapiteltags', 'book_tags' => 'Bogtags', diff --git a/lang/da/settings.php b/lang/da/settings.php index 448b00c4c07..9d83f5004db 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -367,7 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', - 'th' => 'ภาษาไทย', + 'th' => 'Thailandsk', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/de/auth.php b/lang/de/auth.php index 0ae2dcd9799..22205af60b2 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Diese Anmeldedaten stimmen nicht mit unseren Aufzeichnungen überein.', 'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in :seconds Sekunden erneut.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Zu viele Multi-Faktor-Verifizierungsversuche. Bitte versuchen Sie es in :seconds Sekunden erneut.', // Login & Register 'sign_up' => 'Registrieren', diff --git a/lang/de/entities.php b/lang/de/entities.php index a35a5f4c465..7fa7c43bcd4 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => 'Regeln für die automatische Sortierung können von einem Benutzer mit den entsprechenden Berechtigungen im Einstellungsbereich "Listen & Sortieren" erstellt werden.', 'books_sort_named' => 'Buch ":bookName" sortieren', 'books_sort_name' => 'Sortieren nach Namen', 'books_sort_created' => 'Sortieren nach Erstellungsdatum', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Seitenleiste umschalten', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Seiteninhalt', + 'page_contents_none' => 'Es wurden keine Überschriften im Seiteninhalt gefunden.', + 'page_contents_info' => 'Das Inhaltsmenü wird aus allen auf der Seite verwendeten Überschriften generiert.', 'page_tags' => 'Seiten-Schlagwörter', 'chapter_tags' => 'Kapitel-Schlagwörter', 'book_tags' => 'Buch-Schlagwörter', diff --git a/lang/de/settings.php b/lang/de/settings.php index 983de75baf6..29e857098d6 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -207,7 +207,7 @@ '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_controlled_by_page_delete' => 'Kontrolliert durch die Berechtigung zum Löschen einer Seite', 'role_save' => 'Rolle speichern', 'role_users' => 'Dieser Rolle zugeordnete Benutzer', 'role_users_none' => 'Derzeit sind diesem Rollentyp keine Benutzer zugewiesen', @@ -265,7 +265,7 @@ 'users_mfa_x_methods' => ':count Methode konfiguriert|:count Methoden konfiguriert', 'users_mfa_configure' => 'Methoden konfigurieren', 'users_mfa_reset' => 'Setze Multifaktor-Authentifizierung zurück', - 'users_mfa_reset_desc' => 'Dies wird alle konfigurierten Multifaktor-Authentifizierungsmethoden für diesen Nutzer zurücksetzen. Falls Multifaktor-Authentifizierung für eine seiner Rollen erforderlich ist, werden sie aufgefordert, neue Methoden beim nächsten Login zu konfigurieren.', + 'users_mfa_reset_desc' => 'Dies wird alle konfigurierten Multifaktor-Authentifizierungsmethoden für diesen Nutzer zurücksetzen. Falls Multifaktor-Authentifizierung für eine seiner Rollen erforderlich ist, wird der Nutzer aufgefordert, neue Methoden beim nächsten Login zu konfigurieren.', 'users_mfa_reset_confirm' => 'Sind Sie sicher, dass Sie diese Multi-Faktor-Authentifizierungsmethode für diesen Nutzer zurücksetzen möchten?', // API Tokens diff --git a/lang/de_informal/auth.php b/lang/de_informal/auth.php index b91db255170..2414f1aa7fe 100644 --- a/lang/de_informal/auth.php +++ b/lang/de_informal/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Die eingegebenen Anmeldedaten sind ungültig.', 'throttle' => 'Zu viele Anmeldeversuche. Bitte versuche es in :seconds Sekunden erneut.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Zu viele Multi-Faktor-Verifizierungsversuche. Bitte versuche es in :seconds Sekunden erneut.', // Login & Register 'sign_up' => 'Registrieren', diff --git a/lang/de_informal/entities.php b/lang/de_informal/entities.php index 4b4c7e4ef5f..78c1e3319c6 100644 --- a/lang/de_informal/entities.php +++ b/lang/de_informal/entities.php @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => 'Regeln für die automatische Sortierung können von einem Benutzer mit den entsprechenden Berechtigungen im Einstellungsbereich "Listen & Sortieren" erstellt werden.', 'books_sort_named' => 'Buch ":bookName" sortieren', 'books_sort_name' => 'Sortieren nach Namen', 'books_sort_created' => 'Sortieren nach Erstellungsdatum', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Seitenleiste umschalten', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Seiteninhalt', + 'page_contents_none' => 'Es wurden keine Überschriften im Seiteninhalt gefunden.', + 'page_contents_info' => 'Das Inhaltsmenü wird aus allen auf der Seite verwendeten Überschriften generiert.', 'page_tags' => 'Seiten-Schlagwörter', 'chapter_tags' => 'Kapitel-Schlagwörter', 'book_tags' => 'Buch-Schlagwörter', diff --git a/lang/de_informal/settings.php b/lang/de_informal/settings.php index 84e0c8b1042..7c1796afcc6 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -208,7 +208,7 @@ '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_controlled_by_page_delete' => 'Kontrolliert durch die Berechtigung zum Löschen einer Seite', 'role_save' => 'Rolle speichern', 'role_users' => 'Dieser Rolle zugeordnete Benutzer', 'role_users_none' => 'Bisher sind dieser Rolle keine Benutzer zugeordnet', @@ -266,8 +266,8 @@ 'users_mfa_x_methods' => ':count Methode konfiguriert|:count Methoden konfiguriert', 'users_mfa_configure' => 'Methoden konfigurieren', 'users_mfa_reset' => 'Setze Multifaktor-Authentifizierung zurück', - 'users_mfa_reset_desc' => 'Dies wird alle konfigurierten Multifaktor-Authentifizierungsmethoden für diesen Nutzer zurücksetzen. Falls Multifaktor-Authentifizierung für eine seiner Rollen erforderlich ist, werden sie aufgefordert, neue Methoden beim nächsten Login zu konfigurieren.', - 'users_mfa_reset_confirm' => 'Sind Sie sicher, dass Sie diese Multi-Faktor-Authentifizierungsmethode für diesen Nutzer zurücksetzen möchten?', + 'users_mfa_reset_desc' => 'Dies wird alle konfigurierten Multifaktor-Authentifizierungsmethoden für diesen Nutzer zurücksetzen. Falls Multifaktor-Authentifizierung für eine seiner Rollen erforderlich ist, wird der Nutzer aufgefordert, neue Methoden beim nächsten Login zu konfigurieren.', + 'users_mfa_reset_confirm' => 'Bist du sicher, dass du diese Multi-Faktor-Authentifizierungsmethode für diesen Nutzer zurücksetzen möchtest?', // API Tokens 'user_api_token_create' => 'Neuen API-Token erstellen', diff --git a/lang/hu/settings.php b/lang/hu/settings.php index 6dd7de33b54..3aaa91c35b7 100644 --- a/lang/hu/settings.php +++ b/lang/hu/settings.php @@ -367,7 +367,7 @@ 'sk' => 'Slovensky', 'sl' => 'Slovenščina', 'sv' => 'Svenska', - 'th' => 'ภาษาไทย', + 'th' => 'Thai', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/ja/entities.php b/lang/ja/entities.php index eaaddf5b3af..66019fa1aca 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => '自動ソートオプションのルールは、関連する権限を持ったユーザーによって設定の「一覧とソート」エリアで作成できます。', 'books_sort_named' => 'ブック「:bookName」を並べ替え', 'books_sort_name' => '名前で並べ替え', 'books_sort_created' => '作成日で並べ替え', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index 95f33f34004..bca5ed0049f 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -207,7 +207,7 @@ 'role_all' => '全て', 'role_own' => '自身', 'role_controlled_by_asset' => 'このアセットに対し、右記の操作を許可:', - 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', + 'role_controlled_by_page_delete' => 'ページ削除権限を適用', 'role_save' => '役割を保存', 'role_users' => 'この役割を持つユーザー', 'role_users_none' => 'この役割が付与されたユーザーはいません', diff --git a/lang/nl/entities.php b/lang/nl/entities.php index e90aff57573..3695277a71e 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -331,7 +331,7 @@ // Editor Sidebar 'toggle_sidebar' => 'Zijbalk Tonen/Verbergen', - 'page_contents' => 'Page Contents', + 'page_contents' => 'Pagina Inhoud', 'page_contents_none' => 'No headings were found in the page content.', 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', 'page_tags' => 'Pagina Labels', diff --git a/lang/pt/entities.php b/lang/pt/entities.php index 3bdaeb12c8d..c278796a43f 100644 --- a/lang/pt/entities.php +++ b/lang/pt/entities.php @@ -50,8 +50,8 @@ 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', 'import_pending' => 'Pending Imports', 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_continue' => 'Continuar importação', + 'import_continue_desc' => 'Continuar importação', 'import_details' => 'Import Details', 'import_run' => 'Run Import', 'import_size' => ':size Import ZIP Size', diff --git a/lang/pt/errors.php b/lang/pt/errors.php index 522d7f4c81a..e32257e6240 100644 --- a/lang/pt/errors.php +++ b/lang/pt/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Não pode excluir o único administrador', 'users_cannot_delete_guest' => 'Não pode excluir o usuário convidado', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Não foi possível criar o utilizador, pois o envio do endereço eletrónico de convite falhou', // Roles 'role_cannot_be_edited' => 'Este cargo não pode ser editado', @@ -106,7 +106,7 @@ 'back_soon' => 'Voltaremos em breve.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_read' => 'Não foi possível ler o ficheiro ZIP.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', diff --git a/lang/uk/activities.php b/lang/uk/activities.php index 140fec47c7e..21ebcf970d9 100644 --- a/lang/uk/activities.php +++ b/lang/uk/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Користувача було успішно оновлено', 'user_delete' => 'вилучений користувач', 'user_delete_notification' => 'Користувача успішно видалено', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'скинути MFA для користувача', + 'user_mfa_reset_notification' => 'Скидання методів багатофакторної автентифікації', // API Tokens 'api_token_create' => 'створений APi токен', diff --git a/lang/uk/auth.php b/lang/uk/auth.php index cb927cfd47f..9915fa9433a 100644 --- a/lang/uk/auth.php +++ b/lang/uk/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Цей обліковий запис не знайдено.', 'throttle' => 'Забагато спроб входу в систему. Будь ласка, спробуйте ще раз через :seconds секунд.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Занадто багато спроб багатофакторної перевірки. Будь ласка, спробуйте ще раз через :seconds секунд.', // Login & Register 'sign_up' => 'Реєстрація', diff --git a/lang/uk/entities.php b/lang/uk/entities.php index a77b8e77d13..9e775c88bad 100644 --- a/lang/uk/entities.php +++ b/lang/uk/entities.php @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => 'Правила автоматичного сортування можуть бути створені в області налаштувань "Списки і сортування" за допомогою користувача з відповідними дозволами.', 'books_sort_named' => 'Сортувати книгу :bookName', 'books_sort_name' => 'Сортувати за назвою', 'books_sort_created' => 'Сортувати за датою створення', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Перемикач бічної панелі', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Вміст сторінки', + 'page_contents_none' => 'У тексті сторінки не знайдено заголовків.', + 'page_contents_info' => 'Вміст меню створюється з будь-яких форматів заголовків, використовуваних для сторінки.', 'page_tags' => 'Теги сторінки', 'chapter_tags' => 'Теги розділів', 'book_tags' => 'Теги книг', diff --git a/lang/uk/errors.php b/lang/uk/errors.php index e7632359310..8e332bdedfb 100644 --- a/lang/uk/errors.php +++ b/lang/uk/errors.php @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'Секрет, наданий для даного використовуваного токена API є неправильним', 'api_user_no_api_permission' => 'Власник використовуваного токена API не має дозволу здійснювати виклики API', 'api_user_token_expired' => 'Термін дії токена авторизації закінчився', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Дозволяються тільки запити GET при використанні API з автентифікацією на основі cookie', // Settings & Maintenance 'maintenance_test_email_failure' => 'Помилка під час надсилання тестового електронного листа:', diff --git a/lang/uk/settings.php b/lang/uk/settings.php index 02fd6a4f6ee..521dc9578fd 100644 --- a/lang/uk/settings.php +++ b/lang/uk/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Спочатку розділи', 'sort_rule_op_chapters_last' => 'Розділи останні', '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.', + 'sorting_page_limits_desc' => 'Кількість елементів для відображення в різних списках в системі. Зазвичай менша кількість буде більш продуктивною, в той час як більша кількість уникає необхідність натискання на кілька сторінок. Використання кратного 6 рекомендується.', // Maintenance settings 'maint' => 'Обслуговування', @@ -207,7 +207,7 @@ 'role_all' => 'Все', 'role_own' => 'Власне', 'role_controlled_by_asset' => 'Контролюється за об\'єктом, до якого вони завантажуються', - 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', + 'role_controlled_by_page_delete' => 'Керується правами доступу для видалення сторінки', 'role_save' => 'Зберегти роль', 'role_users' => 'Користувачі в цій ролі', 'role_users_none' => 'Наразі жоден користувач не призначений для цієї ролі', @@ -264,9 +264,9 @@ 'users_mfa_desc' => 'Двофакторна аутентифікація додає ще один рівень безпеки для вашого облікового запису.', 'users_mfa_x_methods' => ':count метод налаштовано|:count методів налаштовано', 'users_mfa_configure' => 'Налаштувати Методи', - '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?', + 'users_mfa_reset' => 'Скинути методи багатофакторної автентифікації', + 'users_mfa_reset_desc' => 'Це скине та очистить всі налаштовані методи багатофакторної аутентифікації для цього користувача. Якщо багатофакторна аутентифікація обов\'язкова будь-якою зі своїх ролей, то вона буде запитана налаштувати нові способи при наступному вході в систему.', + 'users_mfa_reset_confirm' => 'Ви впевнені, що хочете скинути багатофакторну аутентифікацію для цього користувача?', // API Tokens 'user_api_token_create' => 'Створити токен API', From cc0b059fa496e265839733ed30a1ba121274dfab Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 9 Jun 2026 12:47:28 +0100 Subject: [PATCH 268/398] Deps: Updated PHP package versions --- composer.lock | 118 ++++++++++++++++++++++++++------------------------ 1 file changed, 61 insertions(+), 57 deletions(-) diff --git a/composer.lock b/composer.lock index 710067eb0a6..e0726839914 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.382.2", + "version": "3.384.5", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "6844cc6421c47d6b96633ab8039045012acbeb27" + "reference": "c7d34f2d60515bd0c307e462268f75877842da4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/6844cc6421c47d6b96633ab8039045012acbeb27", - "reference": "6844cc6421c47d6b96633ab8039045012acbeb27", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c7d34f2d60515bd0c307e462268f75877842da4a", + "reference": "c7d34f2d60515bd0c307e462268f75877842da4a", "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.382.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.384.5" }, - "time": "2026-05-27T18:11:41+00:00" + "time": "2026-06-08T18:25:02+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1179,25 +1179,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.5", + "version": "7.11.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148" + "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7c8d84b39e680315f687e8662a9d6fb0865c5148", - "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c", + "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.11", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1206,7 +1207,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.4", + "guzzlehttp/test-server": "^0.5", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1286,7 +1287,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.5" + "source": "https://github.com/guzzle/guzzle/tree/7.11.1" }, "funding": [ { @@ -1302,24 +1303,25 @@ "type": "tidelift" } ], - "time": "2026-05-27T11:53:46+00:00" + "time": "2026-06-07T22:54:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.4.1", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -1369,7 +1371,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.4.1" + "source": "https://github.com/guzzle/promises/tree/2.5.0" }, "funding": [ { @@ -1385,27 +1387,29 @@ "type": "tidelift" } ], - "time": "2026-05-20T22:57:30+00:00" + "time": "2026-06-02T12:23:43+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.10.3", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130" + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7c1472269227dc6f18930bd903d7a88fe6c52130", - "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -1486,7 +1490,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.10.3" + "source": "https://github.com/guzzle/psr7/tree/2.11.0" }, "funding": [ { @@ -1502,7 +1506,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T11:48:20+00:00" + "time": "2026-06-02T12:30:48+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1803,16 +1807,16 @@ }, { "name": "laravel/framework", - "version": "v12.61.0", + "version": "v12.61.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "1124062a1ca92d290c8bcb9b7f649920fa6816bf" + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/1124062a1ca92d290c8bcb9b7f649920fa6816bf", - "reference": "1124062a1ca92d290c8bcb9b7f649920fa6816bf", + "url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d", + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d", "shasum": "" }, "require": { @@ -2021,7 +2025,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-26T23:41:33+00:00" + "time": "2026-06-04T14:22:52+00:00" }, { "name": "laravel/prompts", @@ -4191,16 +4195,16 @@ }, { "name": "predis/predis", - "version": "v3.4.2", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "2033429520d8997a7815a2485f56abe6d2d0e075" + "reference": "8cc4319c06924c8ff0c5c7eec4243a19e3be32f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075", - "reference": "2033429520d8997a7815a2485f56abe6d2d0e075", + "url": "https://api.github.com/repos/predis/predis/zipball/8cc4319c06924c8ff0c5c7eec4243a19e3be32f1", + "reference": "8cc4319c06924c8ff0c5c7eec4243a19e3be32f1", "shasum": "" }, "require": { @@ -4242,7 +4246,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v3.4.2" + "source": "https://github.com/predis/predis/tree/v3.5.0" }, "funding": [ { @@ -4250,7 +4254,7 @@ "type": "github" } ], - "time": "2026-03-09T20:33:04+00:00" + "time": "2026-06-02T19:25:56+00:00" }, { "name": "psr/clock", @@ -6845,16 +6849,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -6906,7 +6910,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -6926,7 +6930,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", @@ -7014,16 +7018,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "8339098cae28673c15cce00d80734af0453054e2" + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", - "reference": "8339098cae28673c15cce00d80734af0453054e2", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { @@ -7070,7 +7074,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "funding": [ { @@ -7090,7 +7094,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { "name": "symfony/polyfill-php84", @@ -9179,11 +9183,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.0", + "version": "2.2.2", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b4cd98348c809924f62bb9cc8c047f5e73bc9a58", - "reference": "b4cd98348c809924f62bb9cc8c047f5e73bc9a58", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", "shasum": "" }, "require": { @@ -9239,7 +9243,7 @@ "type": "github" } ], - "time": "2026-05-28T08:22:43+00:00" + "time": "2026-06-05T09:00:01+00:00" }, { "name": "phpunit/php-code-coverage", From c74df7e06bb5dcc8c89952b40d277a87ea85807d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 9 Jun 2026 12:50:16 +0100 Subject: [PATCH 269/398] Updated translator & dependency attribution before release v26.05.1 --- .github/translators.txt | 2 ++ dev/licensing/js-library-licenses.txt | 29 +++++---------------------- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/.github/translators.txt b/.github/translators.txt index cae26547bd3..6aa2be470f3 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -544,3 +544,5 @@ FelixFrizzy :: German Pedro de Mattia (pdmtt) :: Portuguese, Brazilian lonestan :: Russian Paul Kernstock (kernstock) :: German +brtbr :: German; German Informal +Ricardo Covelo (covelo12) :: Portuguese diff --git a/dev/licensing/js-library-licenses.txt b/dev/licensing/js-library-licenses.txt index 45d1816dd17..bd3e09f9f6e 100644 --- a/dev/licensing/js-library-licenses.txt +++ b/dev/licensing/js-library-licenses.txt @@ -482,8 +482,8 @@ electron-to-chromium License: ISC License File: node_modules/electron-to-chromium/LICENSE Copyright: Copyright 2018 Kilian Valkhof -Source: git+https://github.com/kilian/electron-to-chromium.git -Link: git+https://github.com/kilian/electron-to-chromium.git +Source: git+https://github.com/Kilian/electron-to-chromium.git +Link: git+https://github.com/Kilian/electron-to-chromium.git ----------- emittery License: MIT @@ -860,7 +860,7 @@ License: MIT License File: node_modules/handlebars/LICENSE Copyright: Copyright (C) 2011-2019 by Yehuda Katz Source: https://github.com/handlebars-lang/handlebars.js.git -Link: https://www.handlebarsjs.com/ +Link: https://handlebarsjs.com/ ----------- has-bigints License: MIT @@ -1663,13 +1663,6 @@ Copyright: Copyright (c) Stephen Sugden <**@*************.***> (stephensugden.co Source: grncdr/merge-stream Link: grncdr/merge-stream ----------- -micromatch -License: MIT -License File: node_modules/micromatch/LICENSE -Copyright: Copyright (c) 2014-present, Jon Schlinkert. -Source: micromatch/micromatch -Link: https://github.com/micromatch/micromatch ------------ mimic-fn License: MIT License File: node_modules/mimic-fn/license @@ -3574,13 +3567,6 @@ Copyright: Copyright (c) 2017-present Devon Govett Source: https://github.com/parcel-bundler/watcher.git Link: https://github.com/parcel-bundler/watcher.git ----------- -@parcel/watcher-linux-x64-musl -License: MIT -License File: node_modules/@parcel/watcher-linux-x64-musl/LICENSE -Copyright: Copyright (c) 2017-present Devon Govett -Source: https://github.com/parcel-bundler/watcher.git -Link: https://github.com/parcel-bundler/watcher.git ------------ @parcel/watcher License: MIT License File: node_modules/@parcel/watcher/LICENSE @@ -3603,8 +3589,8 @@ Link: https://github.com/un-ts/pkgr/blob/master/packages/core License: MIT License File: node_modules/@sinclair/typebox/license Copyright: Copyright (c) 2017-2026 Haydn Paterson -Source: https://github.com/sinclairzx81/typebox-legacy -Link: https://github.com/sinclairzx81/typebox-legacy +Source: https://github.com/sinclairzx81/sinclair-typebox +Link: https://github.com/sinclairzx81/sinclair-typebox ----------- @sinonjs/commons License: BSD-3-Clause @@ -3817,8 +3803,3 @@ Link: https://github.com/ungap/structured-clone#readme License: MIT Source: git+https://github.com/unrs/unrs-resolver.git Link: https://github.com/unrs/unrs-resolver#readme ------------ -@unrs/resolver-binding-linux-x64-musl -License: MIT -Source: git+https://github.com/unrs/unrs-resolver.git -Link: https://github.com/unrs/unrs-resolver#readme From dad83d473d397efa9f437011c4cffb43cbf8c30d Mon Sep 17 00:00:00 2001 From: PolarniMeda Date: Thu, 11 Jun 2026 10:32:03 +0200 Subject: [PATCH 270/398] Added Serbian language to language_select array --- lang/en/settings.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lang/en/settings.php b/lang/en/settings.php index d03024a89d6..0e5ce84cf21 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', From 79a2e017bbf5d4d634e890199e3267d9280f1db1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 11 Jun 2026 14:24:09 +0100 Subject: [PATCH 271/398] Maintenance: Fixed type and CI issues - Fixed issues picked up by PHPStan updates. - Not sure why it was flagging the BookSorter issue, but swapping if statements made it go away. - Updated BookSortMapItem with modern syntax. - Attempted to fix CI issues by adding DOM extension. - Attempted to make migration CI more efficient via tmpfs --- .forgejo/workflows/analyse-php.yml | 2 +- .forgejo/workflows/test-migrations.yml | 10 ++++- .forgejo/workflows/test-php.yml | 2 +- app/Sorting/BookSortMapItem.php | 39 ++++--------------- app/Sorting/BookSorter.php | 2 +- .../HtmlPurifier/ConfiguredHtmlPurifier.php | 1 - 6 files changed, 19 insertions(+), 37 deletions(-) diff --git a/.forgejo/workflows/analyse-php.yml b/.forgejo/workflows/analyse-php.yml index 8975d6e5296..3a07d9bd5c0 100644 --- a/.forgejo/workflows/analyse-php.yml +++ b/.forgejo/workflows/analyse-php.yml @@ -22,7 +22,7 @@ jobs: uses: https://github.com/shivammathur/setup-php@v2 with: php-version: 8.5 - extensions: gd, mbstring, json, curl, xml, mysql, ldap + extensions: gd, mbstring, json, curl, xml, dom, mysql, ldap - name: Get Composer Cache Directory id: composer-cache diff --git a/.forgejo/workflows/test-migrations.yml b/.forgejo/workflows/test-migrations.yml index e969d3e4721..5848f54d901 100644 --- a/.forgejo/workflows/test-migrations.yml +++ b/.forgejo/workflows/test-migrations.yml @@ -23,6 +23,14 @@ jobs: services: mysql: image: docker.io/library/mariadb:12.2.2-noble + options: --tmpfs /var/lib/mysql:rw + cmd: + - --innodb-flush-log-at-trx-commit=0 + - --innodb-flush-method=O_DIRECT + - --innodb-doublewrite=0 + - --innodb-buffer-pool-size=256M + - --skip-log-bin + - --sync-binlog=0 env: MARIADB_USER: bookstack-test MARIADB_PASSWORD: bookstack-test @@ -35,7 +43,7 @@ jobs: uses: https://github.com/shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: gd, mbstring, json, curl, xml, mysql, ldap + extensions: gd, mbstring, json, curl, xml, dom, mysql, ldap - name: Get Composer Cache Directory id: composer-cache diff --git a/.forgejo/workflows/test-php.yml b/.forgejo/workflows/test-php.yml index 06a6de276d2..99e1af518f7 100644 --- a/.forgejo/workflows/test-php.yml +++ b/.forgejo/workflows/test-php.yml @@ -21,7 +21,7 @@ jobs: matrix: php: ['8.2', '8.3', '8.4', '8.5'] env: - phpextensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp + phpextensions: gd, mbstring, json, curl, xml, dom, mysql, ldap, gmp phpextensioncachekey: cache-v1 steps: - uses: https://code.forgejo.org/actions/checkout@v6 diff --git a/app/Sorting/BookSortMapItem.php b/app/Sorting/BookSortMapItem.php index 8f517edd6ff..40137fc49fa 100644 --- a/app/Sorting/BookSortMapItem.php +++ b/app/Sorting/BookSortMapItem.php @@ -4,37 +4,12 @@ class BookSortMapItem { - /** - * @var int - */ - public $id; - - /** - * @var int - */ - public $sort; - - /** - * @var ?int - */ - public $parentChapterId; - - /** - * @var string - */ - public $type; - - /** - * @var int - */ - public $parentBookId; - - public function __construct(int $id, int $sort, ?int $parentChapterId, string $type, int $parentBookId) - { - $this->id = $id; - $this->sort = $sort; - $this->parentChapterId = $parentChapterId; - $this->type = $type; - $this->parentBookId = $parentBookId; + public function __construct( + public int $id, + public int $sort, + public int|null $parentChapterId, + public string $type, + public int $parentBookId, + ) { } } diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index 0862aaa8877..63c66b0fd7a 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -168,7 +168,7 @@ protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMa $model->priority = $sortMapItem->sort; } - if ($chapterChanged || $priorityChanged) { + if ($priorityChanged || $chapterChanged) { $model::withoutTimestamps(fn () => $model->save()); } } diff --git a/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php index 307cdb74f28..87ed5add28e 100644 --- a/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php +++ b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php @@ -38,7 +38,6 @@ public function __construct() $this->configureHtmlDefinition($htmlDef); } - /** @var \HTMLPurifier_URIDefinition $uriDef */ $uriDef = $config->getDefinition('URI', true, true); if ($uriDef instanceof HTMLPurifier_URIDefinition) { $this->configureUriDefinition($uriDef); From c511f7d9356a73cb96e6ecd605736946dcfb6cbd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 24 Jun 2026 13:48:20 +0100 Subject: [PATCH 272/398] CI: Added workflow to sync with snyk --- .forgejo/workflows/update-snyk.yml | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .forgejo/workflows/update-snyk.yml diff --git a/.forgejo/workflows/update-snyk.yml b/.forgejo/workflows/update-snyk.yml new file mode 100644 index 00000000000..64df65b540e --- /dev/null +++ b/.forgejo/workflows/update-snyk.yml @@ -0,0 +1,35 @@ +name: update-snyk + +on: + workflow_dispatch: + push: + paths: + - 'composer*' + - 'package*' + branches: + - 'development' + - 'release' + +jobs: + update: + runs-on: docker + container: + image: docker.io/library/node:24-trixie + steps: + - uses: https://code.forgejo.org/actions/checkout@v6 + + - name: Update Snyk for monitoring - Composer + uses: https://github.com/snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + command: monitor + args: --file=composer.lock --project-name=bookstack-${{forgejo.ref_name}}-composer + + - name: Update Snyk for monitoring - NPM + uses: https://github.com/snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + command: monitor + args: --file=package-lock.json --project-name=bookstack-${{forgejo.ref_name}}-npm \ No newline at end of file From f7df78b91b12ff1d8e248ded7747e76203904b8e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 24 Jun 2026 13:54:01 +0100 Subject: [PATCH 273/398] CI: Attempted to fix snyk workflow --- .forgejo/workflows/update-snyk.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.forgejo/workflows/update-snyk.yml b/.forgejo/workflows/update-snyk.yml index 64df65b540e..89ce68ae3a4 100644 --- a/.forgejo/workflows/update-snyk.yml +++ b/.forgejo/workflows/update-snyk.yml @@ -23,13 +23,11 @@ jobs: env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: - command: monitor - args: --file=composer.lock --project-name=bookstack-${{forgejo.ref_name}}-composer + args: snyk monitor --file=composer.lock --project-name=bookstack-${{forgejo.ref_name}}-composer - name: Update Snyk for monitoring - NPM uses: https://github.com/snyk/actions/node@master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: - command: monitor - args: --file=package-lock.json --project-name=bookstack-${{forgejo.ref_name}}-npm \ No newline at end of file + args: snyk monitor --file=package-lock.json --project-name=bookstack-${{forgejo.ref_name}}-npm \ No newline at end of file From 59bbf504cf1c6205c56c9c0d6c9e3c6154904ecc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 30 Jun 2026 18:35:34 +0100 Subject: [PATCH 274/398] Content filtering: Added srcset protocol filter Upstream libraries used did not specifically treat values in srcset as URIs like other attributes, so this adds a simple filter for possible bad values. Updated tests to cover. Thanks for Gurmandeep Deol for reporting. --- .../HtmlPurifier/ConfiguredHtmlPurifier.php | 4 +++ .../Filters/UriLimitFileProtocolToAnchors.php | 2 -- app/Util/HtmlPurifier/SrcsetAttrDef.php | 26 +++++++++++++++++++ tests/Entity/PageContentFilteringTest.php | 3 +++ 4 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 app/Util/HtmlPurifier/SrcsetAttrDef.php diff --git a/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php index 87ed5add28e..1739359037f 100644 --- a/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php +++ b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php @@ -156,6 +156,10 @@ protected function configureHtmlDefinition(HTMLPurifier_HTMLDefinition $definiti // Allow mention-ids on links $definition->addAttribute('a', 'data-mention-user-id', 'Number'); + + // Set up custom handler for srcset to limit accepted types + $definition->addAttribute('img', 'srcset', new SrcsetAttrDef()); + $definition->addAttribute('source', 'srcset', new SrcsetAttrDef()); } protected function configureUriDefinition(HTMLPurifier_URIDefinition $definition): void diff --git a/app/Util/HtmlPurifier/Filters/UriLimitFileProtocolToAnchors.php b/app/Util/HtmlPurifier/Filters/UriLimitFileProtocolToAnchors.php index 19ca9cc82b9..bf259e08398 100644 --- a/app/Util/HtmlPurifier/Filters/UriLimitFileProtocolToAnchors.php +++ b/app/Util/HtmlPurifier/Filters/UriLimitFileProtocolToAnchors.php @@ -51,5 +51,3 @@ public function filter(&$uri, $config, $context) return false; } } - -// vim: et sw=4 sts=4 diff --git a/app/Util/HtmlPurifier/SrcsetAttrDef.php b/app/Util/HtmlPurifier/SrcsetAttrDef.php new file mode 100644 index 00000000000..3b8417a55e2 --- /dev/null +++ b/app/Util/HtmlPurifier/SrcsetAttrDef.php @@ -0,0 +1,26 @@ +
    ' => '', '
    My local image
    ' => '
    ', '
    My local image
    ' => '
    ', + '
    My local image
    ' => '
    My local image
    ', + '
    cat
    ' => '
    cat
    ', + '' => '', ]; config()->set('app.content_filtering', 'a'); From 01dc1e71c5574ba36dabb55d9b2a3e4a234df2cc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 30 Jun 2026 23:50:11 +0100 Subject: [PATCH 275/398] Attachments: Added more extensive URL filtering Added a central URLFilter class to check & clean URLs used for attachments, which is also used for validation, and by the purifier to standardise protocols (and to make protocol config easier in future). Thanks to mfk25 for reporting. --- .../ValidationRuleServiceProvider.php | 7 +- app/Uploads/Attachment.php | 3 +- .../Controllers/AttachmentController.php | 4 +- .../HtmlPurifier/ConfiguredHtmlPurifier.php | 18 ++- app/Util/UrlFilter.php | 103 ++++++++++++++++++ tests/Uploads/AttachmentTest.php | 28 +++++ tests/Util/UrlFilterTest.php | 71 ++++++++++++ 7 files changed, 218 insertions(+), 16 deletions(-) create mode 100644 app/Util/UrlFilter.php create mode 100644 tests/Util/UrlFilterTest.php diff --git a/app/App/Providers/ValidationRuleServiceProvider.php b/app/App/Providers/ValidationRuleServiceProvider.php index 1adc1ebd851..fc030263029 100644 --- a/app/App/Providers/ValidationRuleServiceProvider.php +++ b/app/App/Providers/ValidationRuleServiceProvider.php @@ -3,6 +3,7 @@ namespace BookStack\App\Providers; use BookStack\Uploads\ImageService; +use BookStack\Util\UrlFilter; use Illuminate\Support\Facades\Validator; use Illuminate\Support\ServiceProvider; @@ -21,10 +22,8 @@ public function boot(): void Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) { $cleanLinkName = strtolower(trim($value)); - $isJs = str_starts_with($cleanLinkName, 'javascript:'); - $isData = str_starts_with($cleanLinkName, 'data:'); - - return !$isJs && !$isData; + $filter = new UrlFilter($cleanLinkName); + return $filter->isAllowed(); }); } } diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 05227243a76..1619bebbcdd 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -10,6 +10,7 @@ use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\OwnableInterface; use BookStack\Users\Models\User; +use BookStack\Util\UrlFilter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -71,7 +72,7 @@ public function jointPermissions(): HasMany public function getUrl($openInline = false): string { if ($this->external && !str_starts_with($this->path, 'http')) { - return $this->path; + return (new UrlFilter($this->path))->clean(); } return url('/attachments/' . $this->id . ($openInline ? '?open=true' : '')); diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index bdd96d6ad7b..aa9e0e29195 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -11,6 +11,7 @@ use BookStack\Permissions\Permission; use BookStack\Uploads\Attachment; use BookStack\Uploads\AttachmentService; +use BookStack\Util\UrlFilter; use Exception; use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Http\Request; @@ -221,7 +222,8 @@ public function get(Request $request, string $attachmentId) } if ($attachment->external) { - return redirect($attachment->path); + $url = (new UrlFilter($attachment->path))->clean(); + return redirect($url); } $fileName = $attachment->getFileName(); diff --git a/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php index 1739359037f..221db6ffc39 100644 --- a/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php +++ b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php @@ -4,6 +4,7 @@ use BookStack\App\AppVersion; use BookStack\Util\HtmlPurifier\Filters\UriLimitFileProtocolToAnchors; +use BookStack\Util\UrlFilter; use HTMLPurifier; use HTMLPurifier_Config; use HTMLPurifier_DefinitionCache_Serializer; @@ -84,16 +85,13 @@ protected function setConfig(HTMLPurifier_Config $config, string $cachePath): vo $config->set('Attr.ID.HTML5', true); $config->set('Output.FixInnerHTML', false); $config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%'); - $config->set('URI.AllowedSchemes', [ - 'http' => true, - 'https' => true, - 'mailto' => true, - 'ftp' => true, - 'nntp' => true, - 'news' => true, - 'tel' => true, - 'file' => true, - ]); + + $allowedSchemes = UrlFilter::getAllowedSchemes(); + $allowedSchemesSetting = []; + foreach ($allowedSchemes as $scheme) { + $allowedSchemesSetting[$scheme] = true; + } + $config->set('URI.AllowedSchemes', $allowedSchemesSetting); // $config->set('Cache.DefinitionImpl', null); // Disable cache during testing } diff --git a/app/Util/UrlFilter.php b/app/Util/UrlFilter.php new file mode 100644 index 00000000000..a3c65d03ec3 --- /dev/null +++ b/app/Util/UrlFilter.php @@ -0,0 +1,103 @@ +url = trim($url); + } + + /** + * Check if the URL is allowed to be generally used as a link + * in the application. This does not assure the original URL string + * provided is safe as-is. Ensure you use the clean method to produce + * a URL that is considered safe to use. + */ + public function isAllowed(): bool + { + $urlParts = parse_url($this->url); + if (!$urlParts) { + return false; + } + + // Extra check to help avoid scenarios where non-standard characters are used in the scheme + // to work around parse_url handling with URLs which may be interpreted by the browser differently. + if (str_contains($this->url, ':') && !preg_match('/^[a-z]+:/i', $this->url)) { + return false; + } + + if (isset($urlParts['scheme'])) { + return in_array(strtolower($urlParts['scheme']), self::$allowedSchemes); + } + + return true; + } + + /** + * Clean the URL to ensure it's valid and only uses the allowed schemes. + * If the URL is not allowed, return a placeholder. + */ + public function clean(): string + { + if (!$this->isAllowed()) { + return '#badlink'; + } + + $urlParts = parse_url($this->url); + if (!$urlParts) { + return '#badlink'; + } + + $url = ''; + + if (isset($urlParts['scheme']) || isset($urlParts['host'])) { + $scheme = strtolower($urlParts['scheme'] ?? 'https'); + $url = $scheme . ':' . (isset($urlParts['host']) ? '//' : ''); + } + + if (isset($urlParts['user']) || isset($urlParts['pass'])) { + $url .= $urlParts['user'] ?? ''; + if (isset($urlParts['pass'])) { + $url .= ':' . $urlParts['pass']; + } + $url .= '@'; + } + + if (isset($urlParts['host'])) { + $url .= $urlParts['host']; + } + if (isset($urlParts['port'])) { + $url .= ':' . $urlParts['port']; + } + if (isset($urlParts['path'])) { + $url .= $urlParts['path']; + } + if (isset($urlParts['query'])) { + $url .= '?' . $urlParts['query']; + } + if (isset($urlParts['fragment'])) { + $url .= '#' . $urlParts['fragment']; + } + + return $url; + } + + /** + * Get schemes that are allowed to be used in content links. + */ + public static function getAllowedSchemes(): array + { + return self::$allowedSchemes; + } +} diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 22e4f4610a0..3f8a2aaf14f 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -315,6 +315,34 @@ public function test_data_and_js_links_cannot_be_attached_to_a_page() } } + public function test_existing_data_and_js_links_do_not_render_link() + { + $this->asAdmin(); + $page = $this->entities->page(); + $attachment = Attachment::factory()->create(['uploaded_to' => $page->id]); + + $links = [ + 'javascript:alert("bunny")', + ' javascript:alert("bunny")', + 'JavaScript:alert("bunny")', + "\t\n\t\nJavaScript:alert(\"bunny\")", + 'data:text/html;bunny', + 'Data:text/html;bunny', + 'Data:text/html;bunny', + 'donk\tscript:alert("bunny")', + "donk\tscript:alert('bunny')", + ]; + + foreach ($links as $link) { + $attachment->path = $link; + $attachment->save(); + + $resp = $this->get($page->getUrl()); + $resp->assertDontSee('bunny', false); + $resp->assertSee('#badlink', false); + } + } + public function test_attachment_delete_only_shows_with_permission() { $this->asAdmin(); diff --git a/tests/Util/UrlFilterTest.php b/tests/Util/UrlFilterTest.php new file mode 100644 index 00000000000..8811b46d9b6 --- /dev/null +++ b/tests/Util/UrlFilterTest.php @@ -0,0 +1,71 @@ +', + 'Data:text/html;bunny', + 'Data:text/html;bunny', + "http://example.com\0javascript:alert(1)", + ]; + + foreach ($urls as $url) { + $filter = new UrlFilter($url); + $this->assertFalse($filter->isAllowed(), "Failed to detect invalid url: {$url}"); + } + } + + public function test_clean() + { + $expectedOutputByInput = [ + 'javascript:alert("bunny")' => '#badlink', + ' javascript:alert("bunny")' => '#badlink', + 'JavaScript:alert("bunny")' => '#badlink', + "\t\n\t\nJavaScript:alert(\"bunny\")" => '#badlink', + 'data:text/html;bunny' => '#badlink', + 'Data:text/html;bunny' => '#badlink', + 'Data:text/html;bunny' => '#badlink', + "http://example.com\0javascript:alert(1)" => '#badlink', + "Java\tScript:alert(\"bunny\")" => '#badlink', + + 'https://example.com' => 'https://example.com', + 'https://example.com/a/b' => 'https://example.com/a/b', + 'https://example.com/a/b?a=b#ab' => 'https://example.com/a/b?a=b#ab', + 'https://example.com/a/b?a=b#ab&c=d' => 'https://example.com/a/b?a=b#ab&c=d', + 'https://example.com:5050' => 'https://example.com:5050', + 'https://example.com:5050/a/b' => 'https://example.com:5050/a/b', + 'https://example.com:5050/a/b?a=b#ab' => 'https://example.com:5050/a/b?a=b#ab', + 'https://user@example.com:5011/a/b?a=b' => 'https://user@example.com:5011/a/b?a=b', + 'https://user:pass@example.com:5011/a/b?a=b' => 'https://user:pass@example.com:5011/a/b?a=b', + + '//example.com' => 'https://example.com', + 'a/b/c' => 'a/b/c', + '/a/b/c' => '/a/b/c', + + 'tel:123456789' => 'tel:123456789', + 'TEL:123456789' => 'tel:123456789', + 'maiLto:a@b.c' => 'mailto:a@b.c', + 'file://a/b/c' => 'file://a/b/c', + 'ftp://a/b/c' => 'ftp://a/b/c', + 'nntp://a/b/c' => 'nntp://a/b/c', + 'news:a/b/c' => 'news:a/b/c', + ]; + + foreach ($expectedOutputByInput as $input => $expected) { + $filter = new UrlFilter($input); + $output = $filter->clean(); + $this->assertEquals($expected, $output, "Failed to clean url: {$input}"); + } + } +} From fe39b69c1f626e09bc5066b9e76a9f29aab14a8d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 1 Jul 2026 10:20:32 +0100 Subject: [PATCH 276/398] Comments: Added visibility check to comment delete Aligns it with other actions/endpoints, and ensures an extra layer of control against malicious use. Thanks to mfk25 for reporting. --- app/Activity/Controllers/CommentController.php | 11 ++++------- tests/Activity/CommentStoreTest.php | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index f61a2c8df6e..8474d9eb1c7 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -59,8 +59,7 @@ public function update(Request $request, int $commentId) 'html' => ['required', 'string'], ]); - $comment = $this->commentRepo->getById($commentId); - $this->checkOwnablePermission(Permission::PageView, $comment->entity); + $comment = $this->commentRepo->getVisibleById($commentId); $this->checkOwnablePermission(Permission::CommentUpdate, $comment); $comment = $this->commentRepo->update($comment, $input['html']); @@ -76,8 +75,7 @@ public function update(Request $request, int $commentId) */ public function archive(int $id) { - $comment = $this->commentRepo->getById($id); - $this->checkOwnablePermission(Permission::PageView, $comment->entity); + $comment = $this->commentRepo->getVisibleById($id); if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) { $this->showPermissionError(); } @@ -96,8 +94,7 @@ public function archive(int $id) */ public function unarchive(int $id) { - $comment = $this->commentRepo->getById($id); - $this->checkOwnablePermission(Permission::PageView, $comment->entity); + $comment = $this->commentRepo->getVisibleById($id); if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) { $this->showPermissionError(); } @@ -116,7 +113,7 @@ public function unarchive(int $id) */ public function destroy(int $id) { - $comment = $this->commentRepo->getById($id); + $comment = $this->commentRepo->getVisibleById($id); $this->checkOwnablePermission(Permission::CommentDelete, $comment); $this->commentRepo->delete($comment); diff --git a/tests/Activity/CommentStoreTest.php b/tests/Activity/CommentStoreTest.php index 2296f91a912..04624e4d5f9 100644 --- a/tests/Activity/CommentStoreTest.php +++ b/tests/Activity/CommentStoreTest.php @@ -105,6 +105,23 @@ public function test_comment_delete() $this->assertActivityExists(ActivityType::COMMENT_DELETE); } + public function test_comment_delete_requires_view_permission_to_page() + { + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, ['comment-delete-all']); + $page = $this->entities->page(); + $this->actingAs($editor); + + $commentData = Comment::factory()->make(); + $this->postJson("/comment/$page->id", $commentData->getAttributes()); + $comment = $page->comments()->first(); + $this->permissions->disableEntityInheritedPermissions($page); + + $resp = $this->deleteJson("/comment/$comment->id"); + $resp->assertStatus(404); + $this->assertDatabaseHas('comments', ['id' => $comment->id]); + } + public function test_comment_archive_and_unarchive() { $this->asAdmin(); From caeea658d117580f801ef55aa450aec2f16d785b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 1 Jul 2026 10:45:59 +0100 Subject: [PATCH 277/398] Access: Hardened usage of referring URLs via login Adds a more substantial URL check, via a new class which is shared and used in other parts of the app for consistency. Thanks to mfk25 for reporting. --- app/Access/Controllers/LoginController.php | 4 +- app/Console/Commands/InstallModuleCommand.php | 9 ++- app/Util/UrlComparison.php | 36 +++++++++++ tests/Auth/AuthTest.php | 16 +++-- tests/Util/UrlComparisonTest.php | 60 +++++++++++++++++++ 5 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 app/Util/UrlComparison.php create mode 100644 tests/Util/UrlComparisonTest.php diff --git a/app/Access/Controllers/LoginController.php b/app/Access/Controllers/LoginController.php index 4694f22e4d3..fece3d88098 100644 --- a/app/Access/Controllers/LoginController.php +++ b/app/Access/Controllers/LoginController.php @@ -8,6 +8,7 @@ use BookStack\Exceptions\LoginAttemptException; use BookStack\Facades\Activity; use BookStack\Http\Controller; +use BookStack\Util\UrlComparison; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -186,7 +187,8 @@ protected function updateIntendedFromPrevious(): void { // Store the previous location for redirect after login $previous = url()->previous(''); - $isPreviousFromInstance = str_starts_with($previous, url('/')); + $comparison = new UrlComparison($previous, url('/')); + $isPreviousFromInstance = $comparison->originsMatch() && $comparison->pathsOverlap(); if (!$previous || !setting('app-public') || !$isPreviousFromInstance) { return; } diff --git a/app/Console/Commands/InstallModuleCommand.php b/app/Console/Commands/InstallModuleCommand.php index 114bfb105d8..8e77474554e 100644 --- a/app/Console/Commands/InstallModuleCommand.php +++ b/app/Console/Commands/InstallModuleCommand.php @@ -7,6 +7,7 @@ use BookStack\Theming\ThemeModuleException; use BookStack\Theming\ThemeModuleManager; use BookStack\Theming\ThemeModuleZip; +use BookStack\Util\UrlComparison; use GuzzleHttp\Psr7\Request; use Illuminate\Console\Command; use Illuminate\Support\Str; @@ -199,7 +200,6 @@ protected function downloadModuleFile(string $location): string|null { $httpRequests = app()->make(HttpRequestService::class); $client = $httpRequests->buildClient(30, ['stream' => true]); - $originalUrl = parse_url($location); $currentLocation = $location; $maxRedirects = 3; $redirectCount = 0; @@ -212,12 +212,11 @@ protected function downloadModuleFile(string $location): string|null if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) { $redirectLocation = $resp->getHeaderLine('Location'); if ($redirectLocation) { - $redirectUrl = parse_url($redirectLocation); - $redirectOriginMatches = ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '') - && ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '') - && ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? ''); + $comparison = new UrlComparison($location, $redirectLocation); + $redirectOriginMatches = $comparison->originsMatch(); if (!$redirectOriginMatches) { + $redirectUrl = parse_url($redirectLocation); $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?"); diff --git a/app/Util/UrlComparison.php b/app/Util/UrlComparison.php new file mode 100644 index 00000000000..66984918d29 --- /dev/null +++ b/app/Util/UrlComparison.php @@ -0,0 +1,36 @@ +a); + $bParts = parse_url($this->b); + + return $aParts['host'] === $bParts['host'] + && $aParts['scheme'] === $bParts['scheme'] + && $aParts['port'] === $bParts['port']; + } + + /** + * Check if there's some overlap between the two URLs' paths. + */ + public function pathsOverlap(): bool + { + $aPath = parse_url($this->a, PHP_URL_PATH) ?? ''; + $bPath = parse_url($this->b, PHP_URL_PATH) ?? ''; + + return str_starts_with($aPath, $bPath) || str_starts_with($bPath, $aPath); + } +} diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index bffd8bbdbcb..b42f7cb40d6 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -70,10 +70,18 @@ public function test_login_intended_redirect_does_not_redirect_to_external_pages config()->set('app.url', 'http://localhost'); $this->setSettings(['app-public' => true]); - $this->get('/login', ['referer' => 'https://example.com']); - $login = $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']); - - $login->assertRedirect('http://localhost'); + $testCases = [ + 'https://example.com', + 'http://localhost.example.com', + 'http://localhost:ab@example.com', + ]; + + foreach ($testCases as $testCase) { + $this->get('/login', ['Referer' => $testCase]); + $login = $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']); + $login->assertRedirect('http://localhost'); + auth()->logout(); + } } public function test_login_intended_redirect_does_not_factor_mfa_routes() diff --git a/tests/Util/UrlComparisonTest.php b/tests/Util/UrlComparisonTest.php new file mode 100644 index 00000000000..af20011fa60 --- /dev/null +++ b/tests/Util/UrlComparisonTest.php @@ -0,0 +1,60 @@ +assertTrue($comparison->originsMatch()); + } + + foreach ($bad as [$a, $b]) { + $comparison = new UrlComparison($a, $b); + $this->assertFalse($comparison->originsMatch()); + } + } + + public function test_paths_overlap() + { + $good = [ + ['https://example.com', 'https://example.com/a/b/c'], + ['https://example.com/', 'https://example.com/a/b/c'], + ['https://example.com/a/b', 'https://example.com/a/b/c'], + ['https://example.com/a/b/c', 'https://example.com/a'], + ['http://donk.com/a/b/c?a=b#cat', 'https://example.com:5005/a/b#hello'], + ]; + + $bad = [ + ['https://example.com/a/c', 'https://example.com/a/b/c/d'], + ['https://example.com/a/c', 'https://example.com/d/a/c'], + ]; + + foreach ($good as [$a, $b]) { + $comparison = new UrlComparison($a, $b); + $this->assertTrue($comparison->pathsOverlap()); + } + + foreach ($bad as [$a, $b]) { + $comparison = new UrlComparison($a, $b); + $this->assertFalse($comparison->pathsOverlap()); + } + } +} From b6e3d304feb199f68b2d04c41956b1b4e5371a72 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Thu, 2 Jul 2026 08:34:05 +0000 Subject: [PATCH 278/398] New Crowdin translations by GitHub Action --- lang/ar/settings.php | 1 + lang/bg/settings.php | 1 + lang/bn/settings.php | 1 + lang/bs/settings.php | 1 + lang/ca/settings.php | 1 + lang/cs/settings.php | 1 + lang/cy/settings.php | 1 + lang/da/settings.php | 1 + lang/de/settings.php | 1 + lang/de_informal/settings.php | 1 + lang/el/settings.php | 1 + lang/es/settings.php | 1 + lang/es_AR/settings.php | 1 + lang/et/settings.php | 1 + lang/eu/settings.php | 1 + lang/fa/settings.php | 1 + lang/fi/settings.php | 1 + lang/fr/settings.php | 1 + lang/he/settings.php | 1 + lang/hr/settings.php | 1 + lang/hu/settings.php | 1 + lang/id/activities.php | 2 +- lang/id/editor.php | 4 +- lang/id/settings.php | 1 + lang/is/settings.php | 1 + lang/it/settings.php | 3 +- lang/ja/settings.php | 1 + lang/ka/settings.php | 1 + lang/ko/settings.php | 1 + lang/ku/settings.php | 1 + lang/lt/settings.php | 1 + lang/lv/settings.php | 1 + lang/nb/settings.php | 1 + lang/ne/settings.php | 1 + lang/nl/settings.php | 1 + lang/nn/settings.php | 1 + lang/pl/activities.php | 4 +- lang/pl/auth.php | 2 +- lang/pl/entities.php | 10 +- lang/pl/errors.php | 2 +- lang/pl/settings.php | 11 +- lang/pt/activities.php | 4 +- lang/pt/auth.php | 2 +- lang/pt/editor.php | 12 +- lang/pt/entities.php | 90 ++-- lang/pt/errors.php | 34 +- lang/pt/notifications.php | 6 +- lang/pt/preferences.php | 4 +- lang/pt/settings.php | 97 ++--- lang/pt/validation.php | 10 +- lang/pt_BR/activities.php | 4 +- lang/pt_BR/auth.php | 2 +- lang/pt_BR/entities.php | 8 +- lang/pt_BR/settings.php | 13 +- lang/ro/settings.php | 1 + lang/ru/settings.php | 1 + lang/sk/settings.php | 1 + lang/sl/settings.php | 1 + lang/sq/settings.php | 1 + lang/sr/activities.php | 36 +- lang/sr/auth.php | 6 +- lang/sr/common.php | 18 +- lang/sr/editor.php | 14 +- lang/sr/entities.php | 784 +++++++++++++++++----------------- lang/sr/errors.php | 174 ++++---- lang/sr/notifications.php | 12 +- lang/sr/pagination.php | 4 +- lang/sr/passwords.php | 10 +- lang/sr/preferences.php | 78 ++-- lang/sr/settings.php | 439 +++++++++---------- lang/sr/validation.php | 170 ++++---- lang/sv/settings.php | 1 + lang/th/settings.php | 1 + lang/tk/settings.php | 1 + lang/tr/settings.php | 1 + lang/uk/settings.php | 3 +- lang/uz/settings.php | 1 + lang/vi/settings.php | 1 + lang/zh_CN/auth.php | 2 +- lang/zh_CN/entities.php | 8 +- lang/zh_CN/settings.php | 7 +- lang/zh_TW/activities.php | 4 +- lang/zh_TW/auth.php | 2 +- lang/zh_TW/entities.php | 8 +- lang/zh_TW/errors.php | 2 +- lang/zh_TW/settings.php | 13 +- 86 files changed, 1107 insertions(+), 1055 deletions(-) diff --git a/lang/ar/settings.php b/lang/ar/settings.php index aa361e04b9a..af02a556411 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/bg/settings.php b/lang/bg/settings.php index 0af97414045..d347510ff0d 100644 --- a/lang/bg/settings.php +++ b/lang/bg/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/bn/settings.php b/lang/bn/settings.php index ab7fe951271..5bceb85f8a4 100644 --- a/lang/bn/settings.php +++ b/lang/bn/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/bs/settings.php b/lang/bs/settings.php index d03024a89d6..0e5ce84cf21 100644 --- a/lang/bs/settings.php +++ b/lang/bs/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/ca/settings.php b/lang/ca/settings.php index ac60ce8e2cb..d5a70545977 100644 --- a/lang/ca/settings.php +++ b/lang/ca/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/cs/settings.php b/lang/cs/settings.php index 85c479dbddb..92425b5e4e0 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/cy/settings.php b/lang/cy/settings.php index 816a4b89ffb..8ac7bbc50b9 100644 --- a/lang/cy/settings.php +++ b/lang/cy/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/da/settings.php b/lang/da/settings.php index 9d83f5004db..c28c9fef0b8 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'Thailandsk', 'tr' => 'Türkçe', diff --git a/lang/de/settings.php b/lang/de/settings.php index 29e857098d6..a202898cf4e 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Russisch', 'sk' => 'Slowenisch', 'sl' => 'Slowenisch', + 'sr' => 'Српски', 'sv' => 'Schwedisch', 'th' => 'ภาษาไทย', 'tr' => 'Türkisch', diff --git a/lang/de_informal/settings.php b/lang/de_informal/settings.php index 7c1796afcc6..f02f6e02559 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -367,6 +367,7 @@ 'ru' => 'Russisch', 'sk' => 'Slowenisch', 'sl' => 'Slowenisch', + 'sr' => 'Српски', 'sv' => 'Schwedisch', 'th' => 'ภาษาไทย', 'tr' => 'Türkisch', diff --git a/lang/el/settings.php b/lang/el/settings.php index 42422a8e7da..01379e71bba 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/es/settings.php b/lang/es/settings.php index 2fe672f83db..1f1b84a310e 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Ruso', 'sk' => 'Eslovaco', 'sl' => 'Esloveno', + 'sr' => 'Српски', 'sv' => 'Sueco', 'th' => 'ภาษาไทย', 'tr' => 'Turco', diff --git a/lang/es_AR/settings.php b/lang/es_AR/settings.php index 5545b91f3bc..0a97aca679c 100644 --- a/lang/es_AR/settings.php +++ b/lang/es_AR/settings.php @@ -367,6 +367,7 @@ 'ru' => 'Ruso', 'sk' => 'Eslovaco', 'sl' => 'Esloveno', + 'sr' => 'Српски', 'sv' => 'Sueco', 'th' => 'ภาษาไทย', 'tr' => 'Turco', diff --git a/lang/et/settings.php b/lang/et/settings.php index 3e28eae95f5..03b602372e0 100644 --- a/lang/et/settings.php +++ b/lang/et/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский (vene keel)', 'sk' => 'Slovensky', 'sl' => 'Sloveenia', + 'sr' => 'Српски', 'sv' => 'Rootsi', 'th' => 'ภาษาไทย', 'tr' => 'Türgi', diff --git a/lang/eu/settings.php b/lang/eu/settings.php index ffa2c182e81..563df987108 100644 --- a/lang/eu/settings.php +++ b/lang/eu/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/fa/settings.php b/lang/fa/settings.php index bb1b1ca7e2d..cf1413b965c 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/fi/settings.php b/lang/fi/settings.php index aa8ac3e592b..3f77d1f154f 100644 --- a/lang/fi/settings.php +++ b/lang/fi/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index 8ff81ba6bb3..697e5ffa06d 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Russe', 'sk' => 'Slovaque', 'sl' => 'Slovène', + 'sr' => 'Српски', 'sv' => 'Suédois', 'th' => 'ภาษาไทย', 'tr' => 'Turc', diff --git a/lang/he/settings.php b/lang/he/settings.php index e816766813b..4c833fae659 100644 --- a/lang/he/settings.php +++ b/lang/he/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/hr/settings.php b/lang/hr/settings.php index a595b5b1605..7d2315c85fc 100644 --- a/lang/hr/settings.php +++ b/lang/hr/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/hu/settings.php b/lang/hu/settings.php index 3aaa91c35b7..0587574a8e6 100644 --- a/lang/hu/settings.php +++ b/lang/hu/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'Thai', 'tr' => 'Türkçe', diff --git a/lang/id/activities.php b/lang/id/activities.php index db32fcf644a..edb23657ec0 100644 --- a/lang/id/activities.php +++ b/lang/id/activities.php @@ -99,7 +99,7 @@ 'user_update_notification' => 'Pengguna berhasil diperbarui', 'user_delete' => 'pengguna yang dihapus', 'user_delete_notification' => 'Pengguna berhasil dihapus', - 'user_mfa_reset' => 'reset MFA for user', + 'user_mfa_reset' => 'atur ulang MFA untuk pengguna', 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', // API Tokens diff --git a/lang/id/editor.php b/lang/id/editor.php index 04999368930..5eadf80666b 100644 --- a/lang/id/editor.php +++ b/lang/id/editor.php @@ -8,8 +8,8 @@ return [ // General editor terms 'general' => 'Umum', - 'advanced' => 'Lanjutan', - 'none' => 'Tidak Ada', + 'advanced' => 'Tingkat lanjut', + 'none' => 'Tidak Satupun', 'cancel' => 'Batal', 'save' => 'Simpan', 'close' => 'Tutup', diff --git a/lang/id/settings.php b/lang/id/settings.php index 5d785314701..73fa631ee09 100644 --- a/lang/id/settings.php +++ b/lang/id/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/is/settings.php b/lang/is/settings.php index cabe31917ef..27400e812c3 100644 --- a/lang/is/settings.php +++ b/lang/is/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/it/settings.php b/lang/it/settings.php index 3b8681840f2..2686440b8bf 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -366,8 +366,9 @@ 'ru' => 'Russo', 'sk' => 'Sloveno', 'sl' => 'Sloveno', + 'sr' => 'Српски', 'sv' => 'Svedese', - 'th' => 'ภาษาไทย', + 'th' => 'Thailandese', 'tr' => 'Turco', 'uk' => 'Ucraino', 'uz' => 'O‘zbekcha', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index bca5ed0049f..5dbb35c0303 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/ka/settings.php b/lang/ka/settings.php index d03024a89d6..0e5ce84cf21 100644 --- a/lang/ka/settings.php +++ b/lang/ka/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 90d501a7cb0..4e6ec78b406 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/ku/settings.php b/lang/ku/settings.php index d03024a89d6..0e5ce84cf21 100644 --- a/lang/ku/settings.php +++ b/lang/ku/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/lt/settings.php b/lang/lt/settings.php index 96ee2bebada..ef8053a55f3 100644 --- a/lang/lt/settings.php +++ b/lang/lt/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/lv/settings.php b/lang/lv/settings.php index 886e0ef0a0c..16c0f30ff1e 100644 --- a/lang/lv/settings.php +++ b/lang/lv/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/nb/settings.php b/lang/nb/settings.php index 1cc5d8e02ca..d859f98f4a6 100644 --- a/lang/nb/settings.php +++ b/lang/nb/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/ne/settings.php b/lang/ne/settings.php index 549a4dc8b73..3a1b5b98d53 100644 --- a/lang/ne/settings.php +++ b/lang/ne/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 9b0fe5ad154..977aa55fcaa 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский (Russisch)', 'sk' => 'Slovensky (Slowaaks)', 'sl' => 'Slovenščina (Sloveens)', + 'sr' => 'Српски', 'sv' => 'Svenska (Zweeds)', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe (Turks)', diff --git a/lang/nn/settings.php b/lang/nn/settings.php index 08709833273..74f4c5ef330 100644 --- a/lang/nn/settings.php +++ b/lang/nn/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/pl/activities.php b/lang/pl/activities.php index 00687187d43..45920dcde34 100644 --- a/lang/pl/activities.php +++ b/lang/pl/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Użytkownik zaktualizowany pomyślnie', 'user_delete' => 'usunięto użytkownika', 'user_delete_notification' => 'Użytkownik pomyślnie usunięty', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'zresetuj MFA dla użytkownika', + 'user_mfa_reset_notification' => 'Przywracanie metod uwierzytelniania wieloskładnikowego', // API Tokens 'api_token_create' => 'utworzono token API', diff --git a/lang/pl/auth.php b/lang/pl/auth.php index 7b4997a6cb0..62b684bd56c 100644 --- a/lang/pl/auth.php +++ b/lang/pl/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Wprowadzone poświadczenia są nieprawidłowe.', 'throttle' => 'Zbyt wiele prób logowania. Spróbuj ponownie za :seconds s.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Zbyt wiele prób weryfikacji wieloskładnikowej. Spróbuj ponownie za :seconds sekund.', // Login & Register 'sign_up' => 'Zarejestruj się', diff --git a/lang/pl/entities.php b/lang/pl/entities.php index ea0ac73670e..6cc7966e28f 100644 --- a/lang/pl/entities.php +++ b/lang/pl/entities.php @@ -170,10 +170,10 @@ 'books_search_this' => 'Wyszukaj w tej książce', 'books_navigation' => 'Nawigacja po książce', 'books_sort' => 'Sortuj zawartość książki', - '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_desc' => 'Przenieś rozdziały i strony w książce w celu reorganizacji jej treści. Można dodać inne książki, które umożliwiają łatwe przenoszenie rozdziałów i stron między książkami. Opcjonalnie reguła automatycznego sortowania może być ustawiona, aby automatycznie sortować zawartość tej książki po jej zmianach.', '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_auto_sort_creation_hint' => 'Reguły opcji automatycznego sortowania mogą być tworzone w obszarze ustawień "Listy i sortowanie" przez użytkownika z odpowiednimi uprawnieniami.', 'books_sort_named' => 'Sortuj książkę :bookName', 'books_sort_name' => 'Sortuj według nazwy', 'books_sort_created' => 'Sortuj według daty utworzenia', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Przełącz pasek boczny', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Zawartość strony', + 'page_contents_none' => 'Nie znaleziono nagłówków w treści strony.', + 'page_contents_info' => 'Menu zawartości jest generowane z dowolnych formatów nagłówków używanych na stronie.', 'page_tags' => 'Tagi strony', 'chapter_tags' => 'Tagi rozdziału', 'book_tags' => 'Tagi książki', diff --git a/lang/pl/errors.php b/lang/pl/errors.php index 244913cac42..3cba1bfb24e 100644 --- a/lang/pl/errors.php +++ b/lang/pl/errors.php @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'Podany sekret dla tego API jest nieprawidłowy', 'api_user_no_api_permission' => 'Właściciel używanego tokenu API nie ma uprawnień do wykonywania zapytań do API', 'api_user_token_expired' => 'Token uwierzytelniania wygasł', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Tylko żądania GET są dozwolone podczas korzystania z API z uwierzytelniania opartego na plikach cookie', // Settings & Maintenance 'maintenance_test_email_failure' => 'Błąd podczas wysyłania testowej wiadomości e-mail:', diff --git a/lang/pl/settings.php b/lang/pl/settings.php index bfa6339d551..3658ba8fdcb 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Rozdziały na początku', 'sort_rule_op_chapters_last' => 'Rozdziały na końcu', 'sorting_page_limits' => 'Limity wyświetlania per strona', - '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' => 'Ustaw ile elementów pokazywać na stronie w różnych listach w systemie. Zazwyczaj mniejsza ilość będzie bardziej wydajna, podczas gdy większa ilość unika konieczności kliknięcia na wiele stron. Zaleca się stosowanie wielokrotności 6 razy.', // Maintenance settings 'maint' => 'Konserwacja', @@ -207,7 +207,7 @@ '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_controlled_by_page_delete' => 'Kontrolowane przez uprawnienia do usuwania stron', 'role_save' => 'Zapisz rolę', 'role_users' => 'Użytkownicy w tej roli', 'role_users_none' => 'Brak użytkowników zapisanych do tej roli', @@ -264,9 +264,9 @@ 'users_mfa_desc' => 'Skonfiguruj uwierzytelnianie wieloskładnikowe jako dodatkową warstwę bezpieczeństwa dla swojego konta użytkownika.', 'users_mfa_x_methods' => ':count metoda skonfigurowana|:count metody skonfigurowane', 'users_mfa_configure' => 'Konfiguruj metody', - '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?', + 'users_mfa_reset' => 'Resetuj metody uwierzytelniania wieloetapowego', + 'users_mfa_reset_desc' => 'Spowoduje to zresetowanie i wyczyszczenie wszystkich skonfigurowanych metod uwierzytelniania wieloetapowego dla tego użytkownika. Jeśli uwierzytelnianie wieloetapowe jest wymagane przez dowolną z ich ról, zostaną poproszone o skonfigurowanie nowych metod przy następnym logowaniu.', + 'users_mfa_reset_confirm' => 'Czy na pewno chcesz zresetować uwierzytelnianie wieloskładnikowe dla tego użytkownika?', // API Tokens 'user_api_token_create' => 'Utwórz klucz API', @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/pt/activities.php b/lang/pt/activities.php index e2ed42c7111..b7ca3d5b474 100644 --- a/lang/pt/activities.php +++ b/lang/pt/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Utilizador atualizado com sucesso', 'user_delete' => 'utilizador eliminado', 'user_delete_notification' => 'Utilizador removido com sucesso', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'reiniciar a MFA para o utilizador', + 'user_mfa_reset_notification' => 'Reinicialização dos métodos de autenticação multifatorial', // API Tokens 'api_token_create' => 'token API criado', diff --git a/lang/pt/auth.php b/lang/pt/auth.php index 453b201689d..cf662b42af8 100644 --- a/lang/pt/auth.php +++ b/lang/pt/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Estas credenciais não coincidem com os nossos registos.', 'throttle' => 'Demasiadas tentativas de acesso. Tente novamente em :seconds segundos.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Foram efetuadas demasiadas tentativas de verificação multifatorial. Por favor, tente novamente daqui a :seconds segundos.', // Login & Register 'sign_up' => 'Registar', diff --git a/lang/pt/editor.php b/lang/pt/editor.php index e3069909c67..2a5a0f5c6e3 100644 --- a/lang/pt/editor.php +++ b/lang/pt/editor.php @@ -48,7 +48,7 @@ 'superscript' => 'Superior à linha', 'subscript' => 'Inferior à linha', 'text_color' => 'Cor do texto', - 'highlight_color' => 'Highlight color', + 'highlight_color' => 'Cor de destaque', 'custom_color' => 'Cor personalizada', 'remove_color' => 'Remover cor', 'background_color' => 'Cor de fundo', @@ -83,9 +83,9 @@ 'table_properties' => 'Propriedades da tabela', 'table_properties_title' => 'Propriedades da Tabela', 'delete_table' => 'Eliminar tabela', - 'table_clear_formatting' => 'Clear table formatting', - 'resize_to_contents' => 'Resize to contents', - 'row_header' => 'Row header', + 'table_clear_formatting' => 'Limpar formatação de tabela', + 'resize_to_contents' => 'Redimensionar para o conteúdo', + 'row_header' => 'Cabeçalho da linha', 'insert_row_before' => 'Inserir linha antes', 'insert_row_after' => 'Inserir linha depois', 'delete_row' => 'Eliminar linha', @@ -149,7 +149,7 @@ 'url' => 'URL', 'text_to_display' => 'Texto a ser exibido', 'title' => 'Título', - 'browse_links' => 'Browse links', + 'browse_links' => 'Procurar ligações', 'open_link' => 'Abrir ligação', 'open_link_in' => 'Abrir ligação em...', 'open_link_current' => 'Janela atual', @@ -167,7 +167,7 @@ 'about_title' => 'Sobre o Editor WYSIWYG', 'editor_license' => 'Editor da licença de direitos autorais', '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_lexical_license_link' => 'Detalhes da licença completa podem ser encontrados aqui.', '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.', 'save_continue' => 'Salvar página e continuar', diff --git a/lang/pt/entities.php b/lang/pt/entities.php index c278796a43f..5038eebcc7a 100644 --- a/lang/pt/entities.php +++ b/lang/pt/entities.php @@ -46,27 +46,27 @@ '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', - 'import_pending_none' => 'No imports have been started.', + 'import_zip_select' => 'Selecione o ficheiro ZIP para enviar', + 'import_zip_validation_errors' => 'Foram detetados erros ao validar o ficheiro ZIP fornecido:', + 'import_pending' => 'Aguardando importação', + 'import_pending_none' => 'Nenhuma importação foi iniciada.', 'import_continue' => 'Continuar importação', - 'import_continue_desc' => 'Continuar importação', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_continue_desc' => 'Verifique o conteúdo a importar a partir do ficheiro ZIP carregado. Quando estiver pronto, execute a importação para adicionar o seu conteúdo a este sistema. O ficheiro ZIP de importação carregado será automaticamente removido após a importação bem-sucedida.', + 'import_details' => 'Detalhes da importação', + 'import_run' => 'Executar Importação', + 'import_size' => ':size Tamanho do ZIP importado', + 'import_uploaded_at' => 'Carregado :relativeTime', + 'import_uploaded_by' => 'Carregado por', + 'import_location' => 'Local de Importação', + 'import_location_desc' => 'Selecione um local de destino para o seu conteúdo importado. Terá de dispor das permissões necessárias para criar conteúdo no local que escolher.', + 'import_delete_confirm' => 'Tem a certeza que pretende eliminar a importação?', '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', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'import_errors' => 'Erros de Importação', + 'import_errors_desc' => 'Ocorreram os seguintes erros durante a tentativa de importação:', + 'breadcrumb_siblings_for_page' => 'Navegar itens do mesmo nível por página', + 'breadcrumb_siblings_for_chapter' => 'Navegar itens do mesmo nível por capítulo', + 'breadcrumb_siblings_for_book' => 'Navegar itens do mesmo nível por livro', + 'breadcrumb_siblings_for_bookshelf' => 'Navegar itens do mesmo nível por estante', // Permissions and restrictions 'permissions' => 'Permissões', @@ -172,8 +172,8 @@ 'books_sort' => 'Ordenar Conteúdos do Livro', '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_auto_sort_active' => 'Ordenação automática ativada: :sortName', + 'books_sort_auto_sort_creation_hint' => 'As regras da opção de ordenação automática podem ser criadas na área de configurações "Listas e Ordenação" por um utilizador com as permissões necessárias.', 'books_sort_named' => 'Ordenar Livro :bookName', 'books_sort_name' => 'Ordenar por Nome', 'books_sort_created' => 'Ordenar por Data de Criação', @@ -235,7 +235,7 @@ 'pages_delete_draft' => 'Eliminar Rascunho de Página', 'pages_delete_success' => 'Página eliminada', 'pages_delete_draft_success' => 'Rascunho de página eliminado', - 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', + 'pages_delete_warning_template' => 'Esta página é atualmente utilizada como modelo de página predefinido para livros ou capítulos. Após a eliminação desta página, estes livros ou capítulos deixarão de ter um modelo de página predefinido atribuído.', 'pages_delete_confirm' => 'Tem certeza que deseja eliminar a página?', 'pages_delete_draft_confirm' => 'Tem certeza que deseja eliminar o rascunho de página?', 'pages_editing_named' => 'A Editar a Página :pageName', @@ -252,8 +252,8 @@ 'pages_edit_switch_to_markdown_clean' => '(Conteúdo Limitado)', 'pages_edit_switch_to_markdown_stable' => '(Conteúdo Estável)', 'pages_edit_switch_to_wysiwyg' => 'Alternar para o editor WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Mudar para o novo WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(Em fase de testes beta)', 'pages_edit_set_changelog' => 'Relatar Alterações', 'pages_edit_enter_changelog_desc' => 'Digite uma breve descrição das alterações efetuadas por si', 'pages_edit_enter_changelog' => 'Inserir Alterações', @@ -273,7 +273,7 @@ 'pages_md_insert_drawing' => 'Inserir Desenho', 'pages_md_show_preview' => 'Mostrar pré-visualização', 'pages_md_sync_scroll' => 'Sincronizar pré-visualização', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => 'Editor de texto simples', 'pages_drawing_unsaved' => 'Encontrado um rascunho não guardado', 'pages_drawing_unsaved_confirm' => 'Dados de um rascunho não guardado foi encontrado de um tentativa anteriormente falhada. Deseja restaurar e continuar a edição desse rascunho?', 'pages_not_in_chapter' => 'A página não está dentro de um capítulo', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Alternar barra lateral', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Conteúdo da Página', + 'page_contents_none' => 'Não foram encontrados títulos no conteúdo da página.', + 'page_contents_info' => 'O índice é gerado a partir dos formatos de título utilizados na página.', 'page_tags' => 'Etiquetas de Página', 'chapter_tags' => 'Etiquetas do Capítulo', 'book_tags' => 'Etiquetas do Livro', @@ -401,11 +401,11 @@ 'comment' => 'Comentário', 'comments' => 'Comentários', 'comment_add' => 'Adicionar Comentário', - 'comment_none' => 'No comments to display', + 'comment_none' => 'Não há comentários para apresentar', 'comment_placeholder' => 'Digite aqui os seus comentários', - 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', - 'comment_archived_threads' => 'Archived Threads', + 'comment_thread_count' => ':count Tópico de comentários|:count Tópicos de comentários', + 'comment_archived_count' => ':count Arquivado', + 'comment_archived_threads' => 'Tópicos Arquivados', 'comment_save' => 'Guardar comentário', 'comment_new' => 'Comentário Novo', 'comment_created' => 'comentado :createDiff', @@ -414,14 +414,14 @@ 'comment_deleted_success' => 'Comentário removido', 'comment_created_success' => 'Comentário adicionado', 'comment_updated_success' => 'Comentário editado', - 'comment_archive_success' => 'Comment archived', - 'comment_unarchive_success' => 'Comment un-archived', - 'comment_view' => 'View comment', - 'comment_jump_to_thread' => 'Jump to thread', + 'comment_archive_success' => 'Comentário arquivado', + 'comment_unarchive_success' => 'Comentário não arquivado', + 'comment_view' => 'Ver comentário', + 'comment_jump_to_thread' => 'Ir para o tópico', 'comment_delete_confirm' => 'Tem a certeza de que deseja eliminar este comentário?', 'comment_in_reply_to' => 'Em resposta à :commentId', - 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', + 'comment_reference' => 'Referência', + 'comment_reference_outdated' => '(Desatualizado)', 'comment_editor_explain' => 'Aqui estão os comentários que foram deixados nesta página. Comentários podem ser adicionados e geridos ao visualizar a página guardada.', // Revision @@ -452,7 +452,7 @@ // References 'references' => 'Referências', 'references_none' => 'Não há referências registadas para este item.', - 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', + 'references_to_desc' => 'A seguir, encontra-se uma lista de todo o conteúdo conhecido no sistema associado a este item.', // Watch Options 'watch' => 'Ver', @@ -470,11 +470,11 @@ 'watch_desc_comments_page' => 'Notificar sobre alterações na página e novos comentários.', 'watch_change_default' => 'Alterar preferências padrão de notificação', 'watch_detail_ignore' => 'Ignorar notificações', - 'watch_detail_new' => 'Watching for new pages', - 'watch_detail_updates' => 'Watching new pages and updates', - 'watch_detail_comments' => 'Watching new pages, updates & comments', - 'watch_detail_parent_book' => 'Watching via parent book', + 'watch_detail_new' => 'A observar novas páginas', + 'watch_detail_updates' => 'A observar novas páginas e atualizações', + 'watch_detail_comments' => 'A observar novas páginas, atualizações e comentários', + 'watch_detail_parent_book' => 'A observar via livro pai', 'watch_detail_parent_book_ignore' => 'A ignorar através do livro pai', - 'watch_detail_parent_chapter' => 'Watching via parent chapter', - 'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter', + 'watch_detail_parent_chapter' => 'A observar via capítulo pai', + 'watch_detail_parent_chapter_ignore' => 'A ignorar via capítulo pai', ]; diff --git a/lang/pt/errors.php b/lang/pt/errors.php index e32257e6240..d6e92290cd4 100644 --- a/lang/pt/errors.php +++ b/lang/pt/errors.php @@ -10,7 +10,7 @@ // Auth 'error_user_exists_different_creds' => 'Um utilizador com o endereço de e-mail :email já existe mas com credenciais diferentes.', - 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', + 'auth_pre_register_theme_prevention' => 'Não foi possível registar a conta de utilizador com os detalhes fornecidos', 'email_already_confirmed' => 'E-mail já foi confirmado. Tente iniciar sessão.', 'email_confirmation_invalid' => 'Este token de confirmação não é válido ou já foi utilizado. Por favor, tente registar-se novamente.', 'email_confirmation_expired' => 'O token de confirmação já expirou. Um novo e-mail foi enviado.', @@ -37,7 +37,7 @@ 'social_driver_not_found' => 'Social driver não encontrado', 'social_driver_not_configured' => 'Os seus parâmetros sociais de :socialAccount não estão corretamente configurados.', 'invite_token_expired' => 'Este link de convite expirou. Alternativamente, pode tentar redefinir a senha da sua conta.', - 'login_user_not_found' => 'A user for this action could not be found.', + 'login_user_not_found' => 'Não foi possível encontrar um utilizador para esta ação.', // System 'path_not_writable' => 'O caminho do arquivo :filePath não pôde ser carregado. Certifique-se de que tem permissões de escrita no servidor.', @@ -51,9 +51,9 @@ 'image_upload_error' => 'Ocorreu um erro no carregamento da imagem', 'image_upload_type_error' => 'O tipo de imagem enviada é inválida', 'image_upload_replace_type' => 'A imagem de substituição deverá ser do mesmo tipo que a anterior', - 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.', - 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.', - 'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.', + 'image_upload_memory_limit' => 'Não foi possível processar o carregamento da imagem e/ou criar miniaturas devido a limites de recursos do sistema.', + 'image_thumbnail_memory_limit' => 'Não foi possível criar variações de tamanho de imagem devido a limites de recursos do sistema.', + 'image_gallery_thumbnail_memory_limit' => 'Não foi possível criar miniaturas da galeria devido a limites de recursos do sistema.', 'drawing_data_not_found' => 'Dados de desenho não puderam ser carregados. Talvez o arquivo de desenho não exista mais ou não tenha permissão para aceder-lhe.', // Attachments @@ -107,16 +107,16 @@ // Import 'import_zip_cant_read' => 'Não foi possível ler o ficheiro ZIP.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_decode_data' => 'Não foi possível encontrar nem descodificar o conteúdo do ficheiro ZIP data.json.', + 'import_zip_no_data' => 'Os dados do ficheiro ZIP não contêm o conteúdo esperado de livro, capítulo ou página.', + 'import_zip_data_too_large' => 'O conteúdo do ficheiro ZIP data.json excede o tamanho máximo de upload definido para a aplicação.', + 'import_validation_failed' => 'A importação do ficheiro ZIP não foi validada devido a erros:', + 'import_zip_failed_notification' => 'Não foi possível importar o ficheiro ZIP.', + 'import_perms_books' => 'Não dispõe das permissões necessárias para criar livros.', + 'import_perms_chapters' => 'Não dispõe das permissões necessárias para criar capítulos.', + 'import_perms_pages' => 'Não dispõe das permissões necessárias para criar páginas.', + 'import_perms_images' => 'Não dispõe das permissões necessárias para criar imagens.', + 'import_perms_attachments' => 'Não dispõe das permissões necessárias para criar anexos.', // API errors 'api_no_authorization_found' => 'Nenhum token de autorização encontrado na requisição', @@ -125,11 +125,11 @@ 'api_incorrect_token_secret' => 'O segredo fornecido para o token de API usado está incorreto', 'api_user_no_api_permission' => 'O proprietário do token de API utilizado não tem permissão para fazer requisições de API', 'api_user_token_expired' => 'O token de autenticação expirou', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Ao utilizar a API com autenticação baseada em “cookies”, apenas são permitidos pedidos GET', // Settings & Maintenance 'maintenance_test_email_failure' => 'Erro lançado ao enviar um e-mail de teste:', // HTTP errors - 'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts', + 'http_ssr_url_no_match' => 'O URL não corresponde aos "hosts" SSR permitidos configurados', ]; diff --git a/lang/pt/notifications.php b/lang/pt/notifications.php index cbe3a511c88..14beff712eb 100644 --- a/lang/pt/notifications.php +++ b/lang/pt/notifications.php @@ -11,11 +11,11 @@ 'updated_page_subject' => 'Página atualizada: :pageName', 'updated_page_intro' => 'Uma página foi atualizada em :appName:', 'updated_page_debounce' => 'Para evitar um grande volume de notificações, durante algum tempo não serão enviadas notificações de edições futuras para esta página através do mesmo editor.', - 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', - 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', + 'comment_mention_subject' => 'Foi mencionado num comentário na página: :pageName', + 'comment_mention_intro' => 'Foi mencionado num comentário no :appName:', 'detail_page_name' => 'Nome da Página:', - 'detail_page_path' => 'Page Path:', + 'detail_page_path' => 'Caminho da página:', 'detail_commenter' => 'Comentador:', 'detail_comment' => 'Comentário:', 'detail_created_by' => 'Criado Por:', diff --git a/lang/pt/preferences.php b/lang/pt/preferences.php index b7308aaf910..b98cfa3691a 100644 --- a/lang/pt/preferences.php +++ b/lang/pt/preferences.php @@ -23,7 +23,7 @@ 'notifications_desc' => 'Controlar as notificações via correio eletrónico quando certas atividades são executadas pelo sistema.', 'notifications_opt_own_page_changes' => 'Notificar quando páginas que possuo sofrem alterações', 'notifications_opt_own_page_comments' => 'Notificar quando comentam páginas que possuo', - 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', + 'notifications_opt_comment_mentions' => 'Notificar-me quando for mencionado num comentário', 'notifications_opt_comment_replies' => 'Notificar respostas aos meus comentários', 'notifications_save' => 'Guardar preferências', 'notifications_update_success' => 'Preferências de notificação foram atualizadas!', @@ -43,7 +43,7 @@ 'profile_email_no_permission' => 'Infelizmente você não tem permissão para alterar seu correio eletrônico. Se você quiser mudar isso, você precisa pedir a um administrador para alterar por você.', 'profile_avatar_desc' => 'Selecione uma imagem que será usada para lhe representar aos outros usuários do sistema. Idealmente, esta imagem deve ser quadrada e sobre 256px em largura e altura.', 'profile_admin_options' => 'Opções de administrador', - 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', + 'profile_admin_options_desc' => 'Poderá encontrar opções adicionais de nível de administrador, como as destinadas a gerir a atribuição de funções, na sua conta de utilizador, na secção "Definições > Utilizadores" da aplicação.', 'delete_account' => 'Excluir Conta', 'delete_my_account' => 'Excluir a Minha Conta', diff --git a/lang/pt/settings.php b/lang/pt/settings.php index 1488db87a73..7ec587d51e1 100644 --- a/lang/pt/settings.php +++ b/lang/pt/settings.php @@ -75,36 +75,36 @@ 'reg_confirm_restrict_domain_placeholder' => 'Nenhuma restrição definida', // Sorting Settings - 'sorting' => 'Lists & Sorting', - 'sorting_book_default' => 'Default Book Sort Rule', - 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', - 'sorting_rules' => 'Sort Rules', - 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', - 'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', - 'sort_rule_create' => 'Create Sort Rule', - 'sort_rule_edit' => 'Edit Sort Rule', - 'sort_rule_delete' => 'Delete Sort Rule', - 'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.', - 'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?', - 'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?', - 'sort_rule_details' => 'Sort Rule Details', - 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.', - 'sort_rule_operations' => 'Sort Operations', - 'sort_rule_operations_desc' => 'Configure the sort actions to be performed 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_available_operations' => 'Available Operations', - 'sort_rule_available_operations_empty' => 'No operations remaining', - 'sort_rule_configured_operations' => 'Configured Operations', - 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', + 'sorting' => 'Listas e ordenação', + 'sorting_book_default' => 'Regra de ordenação padrão dos livros', + 'sorting_book_default_desc' => 'Selecione a regra de ordenação predefinida a aplicar aos novos livros. Isto não afetará os livros existentes e pode ser substituído individualmente para cada livro.', + 'sorting_rules' => 'Regras de Ordenação', + 'sorting_rules_desc' => 'Trata-se de operações de ordenação predefinidas que podem ser aplicadas ao conteúdo do sistema.', + 'sort_rule_assigned_to_x_books' => 'Atribuído a: :count Livro|Atribuído a: :count Livros', + 'sort_rule_create' => 'Criar Regra de Ordenação', + 'sort_rule_edit' => 'Editar Regra de Ordenação', + 'sort_rule_delete' => 'Eliminar Regra de Ordenação', + 'sort_rule_delete_desc' => 'Remova esta regra de ordenação do sistema. Os livros que utilizam esta ordenação voltarão a ser ordenados manualmente.', + 'sort_rule_delete_warn_books' => 'Esta regra de ordenação é atualmente utilizada em :count livro(s). Tem a certeza de que deseja eliminar isto?', + 'sort_rule_delete_warn_default' => 'Esta regra de ordenação é atualmente utilizada em livros. Tem a certeza de que deseja eliminar isto?', + 'sort_rule_details' => 'Detalhes de Regras de Ordenação', + 'sort_rule_details_desc' => 'Defina um nome para esta regra de ordenação, que aparecerá nas listas quando os utilizadores selecionarem uma opção de ordenação.', + 'sort_rule_operations' => 'Operações de Ordenação', + 'sort_rule_operations_desc' => 'Configure as ações de ordenação a executar, selecionando-as na lista de operações disponíveis. Quando utilizadas, as operações serão aplicadas por ordem, de cima para baixo. Quaisquer alterações efetuadas aqui serão aplicadas a todos os livros atribuídos quando se guardar.', + 'sort_rule_available_operations' => 'Operações Disponíveis', + 'sort_rule_available_operations_empty' => 'Não há operações pendentes', + 'sort_rule_configured_operations' => 'Operações Configuradas', + 'sort_rule_configured_operations_empty' => 'Operações de arrastar/adicionar a partir da lista "Operações Disponíveis"', 'sort_rule_op_asc' => '(Asc)', 'sort_rule_op_desc' => '(Desc)', - 'sort_rule_op_name' => 'Name - Alphabetical', - 'sort_rule_op_name_numeric' => 'Name - Numeric', - 'sort_rule_op_created_date' => 'Created Date', - 'sort_rule_op_updated_date' => 'Updated Date', - 'sort_rule_op_chapters_first' => 'Chapters First', - 'sort_rule_op_chapters_last' => 'Chapters Last', - '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.', + 'sort_rule_op_name' => 'Nome - Alfabético', + 'sort_rule_op_name_numeric' => 'Nome - Numérico', + 'sort_rule_op_created_date' => 'Data de criação', + 'sort_rule_op_updated_date' => 'Data de atualização', + 'sort_rule_op_chapters_first' => 'Capítulos: Primeiro', + 'sort_rule_op_chapters_last' => 'Capítulos: Últimos', + 'sorting_page_limits' => 'Limites de Exibição por Página', + 'sorting_page_limits_desc' => 'Defina o número de itens a apresentar por página nas várias listas do sistema. Normalmente, um número mais baixo proporciona melhor desempenho, enquanto um número mais elevado evita a necessidade de percorrer várias páginas. É recomendado utilizar um múltiplo de 6.', // Maintenance settings 'maint' => 'Manutenção', @@ -141,7 +141,7 @@ 'recycle_bin_contents_empty' => 'A reciclagem está atualmente vazia', 'recycle_bin_empty' => 'Esvaziar Reciclagem', 'recycle_bin_empty_confirm' => 'Isto irá destruir permanentemente todos os itens na reciclagem inclusive o conteúdo de cada item. Tem certeza de que a deseja esvaziar?', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_confirm' => 'Esta ação irá eliminar definitivamente este item do sistema, com quaisquer elementos secundários listados abaixo, e não será possível recuperar este conteúdo. Tem a certeza de que deseja eliminar definitivamente este item?', 'recycle_bin_destroy_list' => 'Itens a serem Destruídos', 'recycle_bin_restore_list' => 'Itens a serem Restaurados', 'recycle_bin_restore_confirm' => 'Esta ação irá restaurar o item excluído, inclusive quaisquer elementos filhos, para o seu local original. Se a localização original tiver, entretanto, sido eliminada e estiver agora na reciclagem, o item pai também precisará de ser restaurado.', @@ -194,20 +194,20 @@ 'role_access_api' => 'Aceder à API do sistema', 'role_manage_settings' => 'Gerir as configurações da aplicação', 'role_export_content' => 'Exportar conteúdo', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Importar conteúdo', 'role_editor_change' => 'Alterar editor de página', - 'role_notifications' => 'Receive & manage notifications', - 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', + 'role_notifications' => 'Receber e gerir notificações', + 'role_permission_note_users_and_roles' => 'Tecnicamente, estas permissões também permitirão visualizar e pesquisar utilizadores e papéis no sistema.', 'role_asset' => 'Permissões de Ativos', 'roles_system_warning' => 'Esteja ciente de que o acesso a qualquer uma das três permissões acima pode permitir que um utilizador altere os seus próprios privilégios ou privilégios de outros no sistema. Apenas atribua cargos com essas permissões a utilizadores de confiança.', 'role_asset_desc' => 'Estas permissões controlam o acesso padrão para os ativos dentro do sistema. Permissões em Livros, Capítulos e Páginas serão sobrescritas por estas permissões.', 'role_asset_admins' => 'Os administradores recebem automaticamente acesso a todo o conteúdo, mas estas opções podem mostrar ou ocultar as opções da Interface de Usuário.', 'role_asset_image_view_note' => 'Isto está relacionado com a visibilidade do gerenciador de imagens. O acesso real dos arquivos de imagem enviados dependerá da opção de armazenamento de imagens do sistema.', - 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', + 'role_asset_users_note' => 'Tecnicamente, estas permissões também permitirão visualizar e pesquisar utilizadores no sistema.', '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_controlled_by_page_delete' => 'Controlado pelas permissões de eliminação de páginas', 'role_save' => 'Guardar Cargo', 'role_users' => 'Utilizadores com este cargo', 'role_users_none' => 'Nenhum utilizador está atualmente vinculado a este cargo', @@ -229,8 +229,8 @@ 'users_send_invite_text' => 'Pode escolher enviar a este utilizador um convite por e-mail que o possibilitará definir a sua própria palavra-passe, ou defina você mesmo uma.', 'users_send_invite_option' => 'Enviar convite por e-mail', 'users_external_auth_id' => 'ID de Autenticação Externa', - 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', - 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', + 'users_external_auth_id_desc' => 'Quando se utiliza um sistema de autenticação externo (como SAML2, OIDC ou LDAP), este é o ID que associa este utilizador do BookStack à conta do sistema de autenticação. Pode ignorar este campo se estiver a utilizar a autenticação padrão baseada no e-mail.', + 'users_password_warning' => 'Preencha os campos abaixo apenas se pretender alterar a palavra-passe deste utilizador.', 'users_system_public' => 'Este utilizador representa quaisquer convidados que visitam a aplicação. Não pode ser utilizado para efetuar autenticação, mas é automaticamente atribuído.', 'users_delete' => 'Eliminar Utilizador', 'users_delete_named' => 'Eliminar :userName', @@ -246,7 +246,7 @@ 'users_preferred_language' => 'Linguagem de Preferência', 'users_preferred_language_desc' => 'Esta opção irá alterar o idioma utilizado para a interface de utilizador da aplicação. Isto não afetará nenhum conteúdo criado por utilizadores.', 'users_social_accounts' => 'Contas Sociais', - 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', + 'users_social_accounts_desc' => 'Ver o estado das contas sociais associadas a este utilizador. As contas sociais podem ser utilizadas em complemento ao sistema de autenticação principal para aceder ao sistema.', 'users_social_accounts_info' => 'Aqui pode ligar outras contas para acesso mais rápido. Desligar uma conta não retira a possibilidade de acesso usando-a. Para revogar o acesso ao perfil através da conta social, você deverá fazê-lo na sua conta social.', 'users_social_connect' => 'Contas Associadas', 'users_social_disconnect' => 'Dissociar Conta', @@ -255,7 +255,7 @@ 'users_social_connected' => 'A conta:socialAccount foi associada com sucesso ao seu perfil.', 'users_social_disconnected' => 'A conta:socialAccount foi dissociada com sucesso de seu perfil.', 'users_api_tokens' => 'Tokens de API', - 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_desc' => 'Crie e faça a gestão dos tokens de acesso utilizados para autenticação na API REST do BookStack. As permissões para a API são geridas através do utilizador a quem o token pertence.', 'users_api_tokens_none' => 'Nenhum token de API foi criado para este utilizador', 'users_api_tokens_create' => 'Criar Token', 'users_api_tokens_expires' => 'Expira', @@ -264,9 +264,9 @@ 'users_mfa_desc' => 'Configure a autenticação multi-fatores como uma camada extra de segurança para sua conta de utilizador.', 'users_mfa_x_methods' => ':count método configurado|:count métodos configurados', 'users_mfa_configure' => 'Configurar Métodos', - '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?', + 'users_mfa_reset' => 'Redefinir métodos de autenticação multifator', + 'users_mfa_reset_desc' => 'Isto irá reiniciar e eliminar todos os métodos de autenticação multifator configurados para este utilizador. Se a autenticação multifator for exigida por alguma das suas funções, ser-lhe-á solicitado que configure novos métodos no seu próximo início de sessão.', + 'users_mfa_reset_confirm' => 'Tem a certeza de que deseja repor a autenticação multifator para este utilizador?', // API Tokens 'user_api_token_create' => 'Criar Token de API', @@ -316,13 +316,13 @@ 'webhooks_last_error_message' => 'Última mensagem de erro:', // Licensing - 'licenses' => 'Licenses', - 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', - 'licenses_bookstack' => 'BookStack License', - 'licenses_php' => 'PHP Library Licenses', - 'licenses_js' => 'JavaScript Library Licenses', - 'licenses_other' => 'Other Licenses', - 'license_details' => 'License Details', + 'licenses' => 'Licenças', + 'licenses_desc' => 'Esta página apresenta informações sobre as licenças do BookStack, bem como sobre os projetos e bibliotecas utilizados no BookStack. Muitos dos projetos aqui listados só podem ser utilizados num contexto de desenvolvimento.', + 'licenses_bookstack' => 'Licença de BookStack', + 'licenses_php' => 'Licenças de Bibliotecas PHP', + 'licenses_js' => 'Licenças de Bibliotecas de JavaScript', + 'licenses_other' => 'Outras Licenças', + 'license_details' => 'Detalhes de Licença', //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/pt/validation.php b/lang/pt/validation.php index 17d891cdb16..4d80f85c422 100644 --- a/lang/pt/validation.php +++ b/lang/pt/validation.php @@ -105,11 +105,11 @@ 'url' => 'O formato da URL :attribute é inválido.', 'uploaded' => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.', - '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' => 'O :attribute deve referenciar um ficheiro dentro do ZIP.', + 'zip_file_size' => 'O ficheiro :attribute não deve exceder :size MB.', + 'zip_file_mime' => 'O :attribute deve referenciar um ficheiro do tipo :validTypes, encontrado em :foundType.', + 'zip_model_expected' => 'Era esperado um objeto de dados, mas foi encontrado “:type”.', + 'zip_unique' => 'O :attribute deve ser único para o tipo de objeto dentro do ficheiro ZIP.', // Custom validation lines 'custom' => [ diff --git a/lang/pt_BR/activities.php b/lang/pt_BR/activities.php index e9069564f50..dbadcf1cc22 100644 --- a/lang/pt_BR/activities.php +++ b/lang/pt_BR/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Usuário atualizado com sucesso', 'user_delete' => 'usuário excluído', 'user_delete_notification' => 'Usuário removido com sucesso', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'redefinir MFA para usuário', + 'user_mfa_reset_notification' => 'Redefinir métodos de autenticação de múltiplos fatores', // API Tokens 'api_token_create' => 'token de API criado', diff --git a/lang/pt_BR/auth.php b/lang/pt_BR/auth.php index dfcc47da6f8..e3455e144f2 100644 --- a/lang/pt_BR/auth.php +++ b/lang/pt_BR/auth.php @@ -8,7 +8,7 @@ 'failed' => 'As credenciais fornecidas não puderam ser validadas em nossos registros.', 'throttle' => 'Muitas tentativas de login. Por favor, tente novamente em :seconds segundos.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Muitas tentativas de verificação de multifatores. Por favor tente novamente em :seconds segundos.', // Login & Register 'sign_up' => 'Criar Conta', diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index 7fa0b50c1d1..fafdb80d5ff 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -219,7 +219,7 @@ 'chapters_permissions_active' => 'Permissões de Capítulo Ativas', 'chapters_permissions_success' => 'Permissões de Capítulo Atualizadas', 'chapters_search_this' => 'Pesquisar neste Capítulo', - 'chapter_sort_book' => 'Classificar livro', + 'chapter_sort_book' => 'Ordenar livro', // Pages 'page' => 'Página', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => '', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Conteúdos da página', + 'page_contents_none' => 'Nenhum título foi encontrado no conteúdo da página.', + 'page_contents_info' => 'O menu de conteúdo é gerado a partir de qualquer formato de cabeçalho usado na página.', 'page_tags' => 'Marcadores de Página', 'chapter_tags' => 'Marcadores de Capítulo', 'book_tags' => 'Marcadores de Livro', diff --git a/lang/pt_BR/settings.php b/lang/pt_BR/settings.php index 8129637c8dc..09832d8656b 100644 --- a/lang/pt_BR/settings.php +++ b/lang/pt_BR/settings.php @@ -183,7 +183,7 @@ 'role_details' => 'Detalhes do Perfil', 'role_name' => 'Nome do Perfil', 'role_desc' => 'Breve Descrição do Perfil', - 'role_mfa_enforced' => 'Requer Autenticação Multi-fator', + 'role_mfa_enforced' => 'Requer Autenticação Multifator', 'role_external_auth_id' => 'IDs de Autenticação Externa', 'role_system' => 'Permissões do Sistema', 'role_manage_users' => 'Gerenciar usuários', @@ -260,13 +260,13 @@ 'users_api_tokens_create' => 'Criar Token', 'users_api_tokens_expires' => 'Expira', 'users_api_tokens_docs' => 'Documentação da API', - 'users_mfa' => 'Autenticação de Múltiplos Fatores', - 'users_mfa_desc' => 'A autenticação multi-fator adiciona outra camada de segurança à sua conta.', + 'users_mfa' => 'Autenticação Multifator', + 'users_mfa_desc' => 'A autenticação multifator adiciona uma camada extra de segurança à sua conta.', 'users_mfa_x_methods' => ':count método configurado|:count métodos configurados', 'users_mfa_configure' => 'Configurar Métodos', - '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?', + 'users_mfa_reset' => 'Redefiner Métodos de Autenticação Multifator', + 'users_mfa_reset_desc' => 'Isto irá redefinir e limpar todos os métodos de autenticação multifator configurados para este usuário. Se a autenticação multifator for exigida por qualquer uma de suas funções, você será solicitado a configurar novos métodos em seu próximo login.', + 'users_mfa_reset_confirm' => 'Você tem certeza que deseja remover o método de autenticação multifator?', // API Tokens 'user_api_token_create' => 'Criar Token de API', @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/ro/settings.php b/lang/ro/settings.php index bbbaa34d959..7423c9822b9 100644 --- a/lang/ro/settings.php +++ b/lang/ro/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/ru/settings.php b/lang/ru/settings.php index 4fdd5784ab2..f3a6529ccc2 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/sk/settings.php b/lang/sk/settings.php index e76fefb98e8..52547235326 100644 --- a/lang/sk/settings.php +++ b/lang/sk/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/sl/settings.php b/lang/sl/settings.php index 9bd63a0e88e..71f280ce226 100644 --- a/lang/sl/settings.php +++ b/lang/sl/settings.php @@ -367,6 +367,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/sq/settings.php b/lang/sq/settings.php index d03024a89d6..0e5ce84cf21 100644 --- a/lang/sq/settings.php +++ b/lang/sq/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/sr/activities.php b/lang/sr/activities.php index 555d01adca1..bf6573e9718 100644 --- a/lang/sr/activities.php +++ b/lang/sr/activities.php @@ -77,20 +77,20 @@ 'maintenance_action_run' => 'покренуо акцију одржавања', // Webhooks - 'webhook_create' => 'креиран вебхоок', - 'webhook_create_notification' => 'Вебхоок је успешно креиран', - 'webhook_update' => 'ажуриран вебхоок', - 'webhook_update_notification' => 'Вебхоок је успешно ажуриран', - 'webhook_delete' => 'обрисан вебхоок', - 'webhook_delete_notification' => 'Вебхоок је успешно обрисан', + 'webhook_create' => 'креирана веб закачка', + 'webhook_create_notification' => 'Веб закачка је успешно креирана', + 'webhook_update' => 'ажурирана веб закачка', + 'webhook_update_notification' => 'Веб закачка је успешно ажурирана', + 'webhook_delete' => 'обрисана веб закачка', + 'webhook_delete_notification' => 'Веб закачка је успешно обрисана', // Imports 'import_create' => 'креиран увоз', - 'import_create_notification' => 'Import successfully uploaded', + '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' => 'креирао корисника', @@ -99,8 +99,8 @@ 'user_update_notification' => 'Корисник је успешно ажуриран', 'user_delete' => 'избрисан корисника', 'user_delete_notification' => 'Корисник је успешно уклоњен', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'поништи МФА за корисника', + 'user_mfa_reset_notification' => 'Поништавања начина мултифакторске аутентификације', // API Tokens 'api_token_create' => 'креирао апи токен', @@ -130,12 +130,12 @@ 'comment_delete' => 'обрисан коментар', // Sort Rules - 'sort_rule_create' => 'created sort rule', - 'sort_rule_create_notification' => 'Sort rule successfully created', - 'sort_rule_update' => 'updated sort rule', - 'sort_rule_update_notification' => 'Sort rule successfully updated', - 'sort_rule_delete' => 'deleted sort rule', - 'sort_rule_delete_notification' => 'Sort rule successfully deleted', + '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/sr/auth.php b/lang/sr/auth.php index 96169fe2bab..78b3c76e2de 100644 --- a/lang/sr/auth.php +++ b/lang/sr/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Ови акредитиви се не поклапају са нашом евиденцијом.', 'throttle' => 'Превише покушаја пријаве. Покушајте поново за :seconds секунди.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Превише покушаја потврђивања вишефакторском аутентификацијом. Молим вас покушајте поново за :seconds секунди.', // Login & Register 'sign_up' => 'Региструј се', @@ -89,13 +89,13 @@ 'mfa_setup_remove_confirmation' => 'Да ли сте сигурни да желите да уклоните овај метод вишефакторске аутентификације?', 'mfa_setup_action' => 'Подешавање', 'mfa_backup_codes_usage_limit_warning' => 'Преостало вам је мање од 5 резервних кодова. Генеришите и сачувајте нови сет пре него што вам понестане кодова како бисте спречили да останете без налога.', - 'mfa_option_totp_title' => 'Aplikacije za mobilne uređaje', + 'mfa_option_totp_title' => 'Апликација за мобилне уређаје', 'mfa_option_totp_desc' => 'Да бисте користили вишефакторску аутентификацију, биће вам потребна мобилна апликација која подржава ТОТП, као што је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_desc' => 'Сачувајте списак кодова испод на безбедном месту. Када приступате систему, моћи ћете да користите један од кодова као други механизам за аутентификацију.', 'mfa_gen_backup_codes_download' => 'Преузми кодове', 'mfa_gen_backup_codes_usage_warning' => 'Сваки код се може искористити једном', 'mfa_gen_totp_title' => 'Подешавање мобилне апликације', diff --git a/lang/sr/common.php b/lang/sr/common.php index c5c62db6588..a1efbb7af6c 100644 --- a/lang/sr/common.php +++ b/lang/sr/common.php @@ -12,7 +12,7 @@ 'save' => 'Сачувај', 'continue' => 'Настави', 'select' => 'Изабери', - 'toggle_all' => 'Сакриј/Прикажи све', + 'toggle_all' => 'Укључи све/ништа', 'more' => 'Више', // Form Labels @@ -20,18 +20,18 @@ 'description' => 'Опис', 'role' => 'Улога', 'cover_image' => 'Насловна слика', - 'cover_image_description' => 'Ова слика би требало да буде приближно 440к250px иако ће бити флексибилно скалирана и исечена како би одговарала корисничком интерфејсу у различитим сценаријима по потреби, тако да ће се стварне димензије приказа разликовати.', + 'cover_image_description' => 'Ова слика би требало да буде приближно 440х250px иако ће бити флексибилно скалирана и исечена како би одговарала корисничком интерфејсу у различитим сценаријима по потреби, тако да ће се стварне димензије приказа разликовати.', // Actions 'actions' => 'Радње', - 'view' => 'Преглед', + 'view' => 'Прегледај', 'view_all' => 'Прикажи све', 'new' => 'Ново', 'create' => 'Креирај', - 'update' => 'Ажурирање', + 'update' => 'Ажурирај', 'edit' => 'Уреди', 'archive' => 'Архивирај', - 'unarchive' => 'Un-Archive', + 'unarchive' => 'Деархивирај', 'sort' => 'Разврстај', 'move' => 'Премести', 'copy' => 'Умножи', @@ -40,7 +40,7 @@ 'delete_confirm' => 'Потврди брисање', 'search' => 'Претражи', 'search_clear' => 'Обриши претрагу', - 'reset' => 'Ресетуј', + 'reset' => 'Поништи', 'remove' => 'Уклони', 'add' => 'Додај', 'configure' => 'Конфигуриши', @@ -81,7 +81,7 @@ 'breadcrumb' => 'Навигација', 'status' => 'Стање', 'status_active' => 'Активан', - 'status_inactive' => 'Неактивно', + 'status_inactive' => 'Неактиван', 'never' => 'Никад', 'none' => 'Ништа', @@ -89,7 +89,7 @@ 'homepage' => 'Почетна страна', 'header_menu_expand' => 'Проширите мени заглавља', 'profile_menu' => 'Мени профила', - 'view_profile' => 'Погледај Профил', + 'view_profile' => 'Погледај профил', 'edit_profile' => 'Измени профил', 'dark_mode' => 'Тамни режим', 'light_mode' => 'Светли режим', @@ -111,5 +111,5 @@ 'terms_of_service' => 'Услови коришћења', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Претражи :appName', ]; diff --git a/lang/sr/editor.php b/lang/sr/editor.php index 2756775a9f2..0f64e1272ef 100644 --- a/lang/sr/editor.php +++ b/lang/sr/editor.php @@ -48,8 +48,8 @@ 'superscript' => 'Надскрипт', 'subscript' => 'Субкрипт', 'text_color' => 'Боја текста', - 'highlight_color' => 'Highlight color', - 'custom_color' => 'Боја текста', + 'highlight_color' => 'Боја наглашавања', + 'custom_color' => 'Прилагођена боја', 'remove_color' => 'Уклоните боју', 'background_color' => 'Боја позадине', 'align_left' => 'Поравнај лево', @@ -61,7 +61,7 @@ 'list_task' => 'Листа задатака', 'indent_increase' => 'Повећај увлачење', 'indent_decrease' => 'Умањи увлачење', - 'table' => 'Tabela', + 'table' => 'Табела', 'insert_image' => 'Уметни слику', 'insert_image_title' => 'Убаци/уреди слику', 'insert_link' => 'Убаци/измени везу', @@ -149,11 +149,11 @@ 'url' => 'УРЛ', 'text_to_display' => 'Текст за приказ', 'title' => 'Наслов', - 'browse_links' => 'Browse links', + 'browse_links' => 'Потражи везе', 'open_link' => 'Отвори везу', 'open_link_in' => 'Отвори везу у...', 'open_link_current' => 'Тренутни прозор', - 'open_link_new' => 'Нови Прозор', + 'open_link_new' => 'Нови прозор', 'remove_link' => 'Уклони везу', 'insert_collapsible' => 'Уредите склопиви блок', 'collapsible_unwrap' => 'Одмотати', @@ -166,8 +166,8 @@ '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 који је обезбеђен под МИТ лиценцом.', 'editor_tiny_license_link' => 'Детаље о ауторским правима и лиценци за ТиниМЦЕ можете пронаћи овде.', 'save_continue' => 'Сачувај страницу и настави', diff --git a/lang/sr/entities.php b/lang/sr/entities.php index cd4e732f412..1c872f546fc 100644 --- a/lang/sr/entities.php +++ b/lang/sr/entities.php @@ -24,7 +24,7 @@ 'meta_updated_name' => 'Ажурирано :timeLength од :user', 'meta_owned_name' => 'Власништво :user', 'meta_reference_count' => 'Референтна од :count item|Референтна од :count items', - 'entity_select' => 'Избор ентитета', + 'entity_select' => 'Избор ставке', 'entity_select_lack_permission' => 'Немате потребне дозволе да изаберете ову ставку', 'images' => 'Слике', 'my_recent_drafts' => 'Моји недавни нацрти', @@ -38,39 +38,39 @@ 'export_html' => 'Садржана веб датотека', 'export_pdf' => 'PDF датотека', 'export_text' => 'Датотеке чистог текста', - 'export_md' => 'Markdown File', - 'export_zip' => 'Portable ZIP', + 'export_md' => 'Markdown датотека', + 'export_zip' => 'Портабилан ZIP', 'default_template' => 'Подразумевани шаблон странице', 'default_template_explain' => 'Доделите шаблон странице који ће се користити као подразумевани садржај за све странице креиране у оквиру ове ставке. Имајте на уму да ће се ово користити само ако креатор странице има приступ за преглед изабране странице шаблона.', 'default_template_select' => 'Изаберите страницу са шаблоном', - 'import' => 'Import', - 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', + 'import' => 'Увоз', + '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' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', - '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', + 'import_continue_desc' => 'Проверите садржај који ће бити увезен из отпремљене ZIP датотеке. Када сте спремни, покрените увоз да би сте додали садржај у систем. Отпремљена ZIP датотека ће аутоматски бити уклоњена по успешном увозу.', + 'import_details' => 'Детаљи увоза', + 'import_run' => 'Покрени увоз', + 'import_size' => ':size величина увозног ZIP-а', + '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_desc' => 'Овде подесите дозволе да бисте заменили подразумеване дозволе које дају корисничке улоге.', 'permissions_book_cascade' => 'Дозволе постављене за књиге ће се аутоматски пребацивати на подређена поглавља и странице, осим ако немају дефинисане сопствене дозволе.', 'permissions_chapter_cascade' => 'Дозволе постављене на поглављима ће се аутоматски каскадно пребацивати на подређене странице, осим ако немају дефинисане сопствене дозволе.', 'permissions_save' => 'Сачувај дозволе', @@ -84,397 +84,397 @@ 'search_results' => 'Резултати претраге', 'search_total_results_found' => ':count пронађених резултата|:count укупно пронађених резултата', 'search_clear' => 'Обриши претрагу', - 'search_no_pages' => 'No pages matched this search', - 'search_for_term' => 'Search for :term', - 'search_more' => 'More Results', - 'search_advanced' => 'Advanced Search', - 'search_terms' => 'Search Terms', - 'search_content_type' => 'Content Type', - 'search_exact_matches' => 'Exact Matches', - 'search_tags' => 'Tag Searches', - 'search_options' => 'Options', - 'search_viewed_by_me' => 'Viewed by me', - 'search_not_viewed_by_me' => 'Not viewed by me', - 'search_permissions_set' => 'Permissions set', - 'search_created_by_me' => 'Created by me', - 'search_updated_by_me' => 'Updated by me', - 'search_owned_by_me' => 'Owned by me', - 'search_date_options' => 'Date Options', - 'search_updated_before' => 'Updated before', - 'search_updated_after' => 'Updated after', - 'search_created_before' => 'Created before', - 'search_created_after' => 'Created after', - 'search_set_date' => 'Set Date', - 'search_update' => 'Update Search', + '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' => 'Shelf', + 'shelf' => 'Полица', 'shelves' => 'Полице', - 'x_shelves' => ':count Shelf|:count Shelves', - 'shelves_empty' => 'No shelves have been created', - 'shelves_create' => 'Create New Shelf', - 'shelves_popular' => 'Popular Shelves', - 'shelves_new' => 'New Shelves', - 'shelves_new_action' => 'New Shelf', - 'shelves_popular_empty' => 'The most popular shelves will appear here.', - 'shelves_new_empty' => 'The most recently created shelves will appear here.', - 'shelves_save' => 'Save Shelf', - 'shelves_books' => 'Books on this shelf', - 'shelves_add_books' => 'Add books to this shelf', - 'shelves_drag_books' => 'Drag books below to add them to this shelf', - 'shelves_empty_contents' => 'This shelf has no books assigned to it', - 'shelves_edit_and_assign' => 'Edit shelf to assign books', - 'shelves_edit_named' => 'Edit Shelf :name', - 'shelves_edit' => 'Edit Shelf', - 'shelves_delete' => 'Delete Shelf', - 'shelves_delete_named' => 'Delete Shelf :name', - 'shelves_delete_explain' => "This will delete the shelf with the name ':name'. Contained books will not be deleted.", - 'shelves_delete_confirmation' => 'Are you sure you want to delete this shelf?', - 'shelves_permissions' => 'Shelf Permissions', - 'shelves_permissions_updated' => 'Shelf Permissions Updated', - 'shelves_permissions_active' => 'Shelf Permissions Active', - 'shelves_permissions_cascade_warning' => 'Permissions on shelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.', - 'shelves_permissions_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.', - 'shelves_copy_permissions_to_books' => 'Copy Permissions to Books', - 'shelves_copy_permissions' => 'Copy Permissions', - 'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.', - 'shelves_copy_permission_success' => 'Shelf permissions copied to :count books', + 'x_shelves' => ':count полица|: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' => 'Book', - 'books' => 'Books', - 'x_books' => ':count Book|:count Books', - 'books_empty' => 'No books have been created', - 'books_popular' => 'Popular Books', - 'books_recent' => 'Recent Books', - 'books_new' => 'New Books', - 'books_new_action' => 'New Book', - 'books_popular_empty' => 'The most popular books will appear here.', - 'books_new_empty' => 'The most recently created books will appear here.', - 'books_create' => 'Create New Book', - 'books_delete' => 'Delete Book', - 'books_delete_named' => 'Delete Book :bookName', - 'books_delete_explain' => 'This will delete the book with the name \':bookName\'. All pages and chapters will be removed.', - 'books_delete_confirmation' => 'Are you sure you want to delete this book?', - 'books_edit' => 'Edit Book', - 'books_edit_named' => 'Edit Book :bookName', - 'books_form_book_name' => 'Book Name', - 'books_save' => 'Save Book', - 'books_permissions' => 'Book Permissions', - 'books_permissions_updated' => 'Book Permissions Updated', - 'books_empty_contents' => 'No pages or chapters have been created for this book.', - 'books_empty_create_page' => 'Create a new page', - 'books_empty_sort_current_book' => 'Sort the current book', - 'books_empty_add_chapter' => 'Add a chapter', - 'books_permissions_active' => 'Book Permissions Active', - 'books_search_this' => 'Search this book', - 'books_navigation' => 'Book Navigation', - 'books_sort' => 'Sort Book Contents', - 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort 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', - 'books_sort_updated' => 'Sort by Updated Date', - 'books_sort_chapters_first' => 'Chapters First', - 'books_sort_chapters_last' => 'Chapters Last', - 'books_sort_show_other' => 'Show Other Books', - 'books_sort_save' => 'Save New Order', - 'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.', - 'books_sort_move_up' => 'Move Up', - 'books_sort_move_down' => 'Move Down', - 'books_sort_move_prev_book' => 'Move to Previous Book', - 'books_sort_move_next_book' => 'Move to Next Book', - 'books_sort_move_prev_chapter' => 'Move Into Previous Chapter', - 'books_sort_move_next_chapter' => 'Move Into Next Chapter', - 'books_sort_move_book_start' => 'Move to Start of Book', - 'books_sort_move_book_end' => 'Move to End of Book', - 'books_sort_move_before_chapter' => 'Move to Before Chapter', - 'books_sort_move_after_chapter' => 'Move to After Chapter', - 'books_copy' => 'Copy Book', - 'books_copy_success' => 'Book successfully copied', + 'book' => 'Књига', + 'books' => 'Књиге', + 'x_books' => ':count књига|: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' => 'Chapter', - 'chapters' => 'Chapters', - 'x_chapters' => ':count Chapter|:count Chapters', - 'chapters_popular' => 'Popular Chapters', - 'chapters_new' => 'New Chapter', - 'chapters_create' => 'Create New Chapter', - 'chapters_delete' => 'Delete Chapter', - 'chapters_delete_named' => 'Delete Chapter :chapterName', - 'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.', - 'chapters_delete_confirm' => 'Are you sure you want to delete this chapter?', - 'chapters_edit' => 'Edit Chapter', - 'chapters_edit_named' => 'Edit Chapter :chapterName', - 'chapters_save' => 'Save Chapter', - 'chapters_move' => 'Move Chapter', - 'chapters_move_named' => 'Move Chapter :chapterName', - 'chapters_copy' => 'Copy Chapter', - 'chapters_copy_success' => 'Chapter successfully copied', - 'chapters_permissions' => 'Chapter Permissions', - 'chapters_empty' => 'No pages are currently in this chapter.', - 'chapters_permissions_active' => 'Chapter Permissions Active', - 'chapters_permissions_success' => 'Chapter Permissions Updated', - 'chapters_search_this' => 'Search this chapter', - 'chapter_sort_book' => 'Sort Book', + 'chapter' => 'Поглавље', + 'chapters' => 'Поглавља', + 'x_chapters' => ':count поглавље|: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' => 'Page', - 'pages' => 'Pages', - 'x_pages' => ':count Page|:count Pages', - 'pages_popular' => 'Popular Pages', - 'pages_new' => 'New Page', - 'pages_attachments' => 'Attachments', - 'pages_navigation' => 'Page Navigation', - 'pages_delete' => 'Delete Page', - 'pages_delete_named' => 'Delete Page :pageName', - 'pages_delete_draft_named' => 'Delete Draft Page :pageName', - 'pages_delete_draft' => 'Delete Draft Page', - 'pages_delete_success' => 'Page deleted', - 'pages_delete_draft_success' => 'Draft page deleted', - 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', - 'pages_delete_confirm' => 'Are you sure you want to delete this page?', - 'pages_delete_draft_confirm' => 'Are you sure you want to delete this draft page?', - 'pages_editing_named' => 'Editing Page :pageName', - 'pages_edit_draft_options' => 'Draft Options', - 'pages_edit_save_draft' => 'Save Draft', - 'pages_edit_draft' => 'Edit Page Draft', - 'pages_editing_draft' => 'Editing Draft', - 'pages_editing_page' => 'Editing Page', - 'pages_edit_draft_save_at' => 'Draft saved at ', - 'pages_edit_delete_draft' => 'Delete Draft', - 'pages_edit_delete_draft_confirm' => 'Are you sure you want to delete your draft page changes? All of your changes, since the last full save, will be lost and the editor will be updated with the latest page non-draft save state.', - 'pages_edit_discard_draft' => 'Discard Draft', - 'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor', - 'pages_edit_switch_to_markdown_clean' => '(Clean Content)', - 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', - 'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)', - 'pages_edit_set_changelog' => 'Set Changelog', - 'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made', - 'pages_edit_enter_changelog' => 'Enter Changelog', - 'pages_editor_switch_title' => 'Switch Editor', - 'pages_editor_switch_are_you_sure' => 'Are you sure you want to change the editor for this page?', - 'pages_editor_switch_consider_following' => 'Consider the following when changing editors:', - 'pages_editor_switch_consideration_a' => 'Once saved, the new editor option will be used by any future editors, including those that may not be able to change editor type themselves.', - 'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.', - 'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.', - 'pages_save' => 'Save Page', - 'pages_title' => 'Page Title', - 'pages_name' => 'Page Name', - 'pages_md_editor' => 'Editor', - 'pages_md_preview' => 'Preview', - 'pages_md_insert_image' => 'Insert Image', - 'pages_md_insert_link' => 'Insert Entity Link', - 'pages_md_insert_drawing' => 'Insert Drawing', - 'pages_md_show_preview' => 'Show preview', - 'pages_md_sync_scroll' => 'Sync preview scroll', - 'pages_md_plain_editor' => 'Plaintext editor', - 'pages_drawing_unsaved' => 'Unsaved Drawing Found', - 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', - 'pages_not_in_chapter' => 'Page is not in a chapter', - 'pages_move' => 'Move Page', - 'pages_copy' => 'Copy Page', - 'pages_copy_desination' => 'Copy Destination', - 'pages_copy_success' => 'Page successfully copied', - 'pages_permissions' => 'Page Permissions', - 'pages_permissions_success' => 'Page permissions updated', - 'pages_revision' => 'Revision', - 'pages_revisions' => 'Page Revisions', - 'pages_revisions_desc' => 'Listed below are all the past revisions of this page. You can look back upon, compare, and restore old page versions if permissions allow. The full history of the page may not be fully reflected here since, depending on system configuration, old revisions could be auto-deleted.', - 'pages_revisions_named' => 'Page Revisions for :pageName', - 'pages_revision_named' => 'Page Revision for :pageName', - 'pages_revision_restored_from' => 'Restored from #:id; :summary', - 'pages_revisions_created_by' => 'Created By', - 'pages_revisions_date' => 'Revision Date', + 'page' => 'Страна', + 'pages' => 'Стране', + 'x_pages' => ':count страна|: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' => 'Пребаци се на Маркдаун уређивач', + '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' => '(у бета тестирању)', + 'pages_edit_set_changelog' => 'Напомена о изменама', + 'pages_edit_enter_changelog_desc' => 'Унесите кратак опис измена које сте извршили', + 'pages_edit_enter_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' => 'Ознаке или записи промена, начињени након последњег снимања, неће се задржати преко ове измене.', + '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' => 'Revision Number', - 'pages_revisions_numbered' => 'Revision #:id', - 'pages_revisions_numbered_changes' => 'Revision #:id Changes', - 'pages_revisions_editor' => 'Editor Type', - 'pages_revisions_changelog' => 'Changelog', - 'pages_revisions_changes' => 'Changes', - 'pages_revisions_current' => 'Current Version', - 'pages_revisions_preview' => 'Preview', - 'pages_revisions_restore' => 'Restore', - 'pages_revisions_none' => 'This page has no revisions', - 'pages_copy_link' => 'Copy Link', - 'pages_edit_content_link' => 'Jump to section in editor', - 'pages_pointer_enter_mode' => 'Enter section select mode', - 'pages_pointer_label' => 'Page Section Options', - 'pages_pointer_permalink' => 'Page Section Permalink', - 'pages_pointer_include_tag' => 'Page Section Include Tag', - 'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag', - 'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink', - 'pages_permissions_active' => 'Page Permissions Active', - 'pages_initial_revision' => 'Initial publish', - 'pages_references_update_revision' => 'System auto-update of internal links', - 'pages_initial_name' => 'New Page', - 'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.', - 'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.', - 'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.', + 'pages_revisions_sort_number' => 'Број ревизије', + 'pages_revisions_numbered' => 'Ревизија #:id', + 'pages_revisions_numbered_changes' => 'Промене ревизије #:id', + 'pages_revisions_editor' => 'Тип уређивача', + 'pages_revisions_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 users have started editing this page', - 'start_b' => ':userName has started editing this page', - 'time_a' => 'since the page was last updated', - 'time_b' => 'in the last :minCount minutes', - 'message' => ':start :time. Take care not to overwrite each other\'s updates!', + 'start_a' => ':count корисника је започело уређивање ове стране', + 'start_b' => ':userName је започео уређивање ове стране', + 'time_a' => 'од када је страна последи пут ажурирана', + 'time_b' => 'у последњих:minCount минута', + 'message' => ':start :time. Водите рачуна да једни другима не препишете измене!', ], - 'pages_draft_discarded' => 'Draft discarded! The editor has been updated with the current page content', - 'pages_draft_deleted' => 'Draft deleted! The editor has been updated with the current page content', - 'pages_specific' => 'Specific Page', - 'pages_is_template' => 'Page Template', + 'pages_draft_discarded' => 'Нацрт је одбачен! Уређивач је ажуриран са тренутним садржајем стране', + 'pages_draft_deleted' => 'Нацрт је обисан! Уређивач је ажуриран са тренутним садржајем стране', + 'pages_specific' => 'Одређена страна', + 'pages_is_template' => 'Шаблон стране', // Editor Sidebar - 'toggle_sidebar' => 'Toggle Sidebar', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', - 'page_tags' => 'Page Tags', - 'chapter_tags' => 'Chapter Tags', - 'book_tags' => 'Book Tags', - 'shelf_tags' => 'Shelf Tags', - 'tag' => 'Tag', - 'tags' => 'Tags', - 'tags_index_desc' => 'Tags can be applied to content within the system to apply a flexible form of categorization. Tags can have both a key and value, with the value being optional. Once applied, content can then be queried using the tag name and value.', - 'tag_name' => 'Tag Name', - 'tag_value' => 'Tag Value (Optional)', - 'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.", - 'tags_add' => 'Add another tag', - 'tags_remove' => 'Remove this tag', - 'tags_usages' => 'Total tag usages', - 'tags_assigned_pages' => 'Assigned to Pages', - 'tags_assigned_chapters' => 'Assigned to Chapters', - 'tags_assigned_books' => 'Assigned to Books', - 'tags_assigned_shelves' => 'Assigned to Shelves', - 'tags_x_unique_values' => ':count unique values', - 'tags_all_values' => 'All values', - 'tags_view_tags' => 'View Tags', - 'tags_view_existing_tags' => 'View existing tags', - 'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.', - 'attachments' => 'Attachments', - 'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.', - 'attachments_explain_instant_save' => 'Changes here are saved instantly.', - 'attachments_upload' => 'Upload File', - 'attachments_link' => 'Attach Link', - 'attachments_upload_drop' => 'Alternatively you can drag and drop a file here to upload it as an attachment.', - 'attachments_set_link' => 'Set Link', - 'attachments_delete' => 'Are you sure you want to delete this attachment?', - 'attachments_dropzone' => 'Drop files here to upload', - 'attachments_no_files' => 'No files have been uploaded', - 'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.', - 'attachments_link_name' => 'Link Name', - 'attachment_link' => 'Attachment link', - 'attachments_link_url' => 'Link to file', - 'attachments_link_url_hint' => 'Url of site or file', - 'attach' => 'Attach', - 'attachments_insert_link' => 'Add Attachment Link to Page', - 'attachments_edit_file' => 'Edit File', - 'attachments_edit_file_name' => 'File Name', - 'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite', - 'attachments_order_updated' => 'Attachment order updated', - 'attachments_updated_success' => 'Attachment details updated', - 'attachments_deleted' => 'Attachment deleted', - 'attachments_file_uploaded' => 'File successfully uploaded', - 'attachments_file_updated' => 'File successfully updated', - 'attachments_link_attached' => 'Link successfully attached to page', - 'templates' => 'Templates', - 'templates_set_as_template' => 'Page is a template', - 'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.', - 'templates_replace_content' => 'Replace page content', - 'templates_append_content' => 'Append to page content', - 'templates_prepend_content' => 'Prepend to page content', + 'toggle_sidebar' => 'Приказ помоћне траке', + 'page_contents' => 'Садржај стране', + 'page_contents_none' => 'Није пронађено ниједно заглавље у садржају стране.', + 'page_contents_info' => 'Мени садржаја се генерише према форматима заглавља коришћеним на овој страни.', + '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' => 'Адреса сајта или датотеке', + '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' => 'User for :time', - 'profile_created_content' => 'Created Content', - 'profile_not_created_pages' => ':userName has not created any pages', - 'profile_not_created_chapters' => ':userName has not created any chapters', - 'profile_not_created_books' => ':userName has not created any books', - 'profile_not_created_shelves' => ':userName has not created any shelves', + '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' => 'Comment', - 'comments' => 'Comments', - 'comment_add' => 'Add Comment', - 'comment_none' => 'No comments to display', - 'comment_placeholder' => 'Leave a comment here', - 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', - 'comment_archived_threads' => 'Archived Threads', - 'comment_save' => 'Save Comment', - 'comment_new' => 'New Comment', - 'comment_created' => 'commented :createDiff', - 'comment_updated' => 'Updated :updateDiff by :username', - 'comment_updated_indicator' => 'Updated', - 'comment_deleted_success' => 'Comment deleted', - 'comment_created_success' => 'Comment added', - 'comment_updated_success' => 'Comment updated', - 'comment_archive_success' => 'Comment archived', - 'comment_unarchive_success' => 'Comment un-archived', - 'comment_view' => 'View comment', - 'comment_jump_to_thread' => 'Jump to thread', - 'comment_delete_confirm' => 'Are you sure you want to delete this comment?', - 'comment_in_reply_to' => 'In reply to :commentId', - 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', - 'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.', + 'comment' => 'Коментар', + 'comments' => 'Коментари', + 'comment_add' => 'Додај коментар', + 'comment_none' => 'Нема коментара за приказ', + 'comment_placeholder' => 'Оставите коментар овде', + 'comment_thread_count' => ':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' => 'Are you sure you want to delete this revision?', - 'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.', - 'revision_cannot_delete_latest' => 'Cannot delete the latest revision.', + 'revision_delete_confirm' => 'Да ли заиста желите да обришете ову ревизију?', + 'revision_restore_confirm' => 'Да ли заиста желите да вратите ову ревизију? Садржај тренутне стране ће бити замењен.', + 'revision_cannot_delete_latest' => 'Није могуће обрисати последњу ревизију.', // Copy view - 'copy_consider' => 'Please consider the below when copying content.', - 'copy_consider_permissions' => 'Custom permission settings will not be copied.', - 'copy_consider_owner' => 'You will become the owner of all copied content.', - 'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.', - 'copy_consider_attachments' => 'Page attachments will not be copied.', - 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', + 'copy_consider' => 'Узмите у обзир следеће када умножавате садржај.', + 'copy_consider_permissions' => 'Поставке прилагођених дозвола неће бити умножене.', + 'copy_consider_owner' => 'Постаћете власник свег умноженог садржаја.', + 'copy_consider_images' => 'Датотеке слика стране неће бити умножене и оригиналне слике ће задржати однос према страни на коју су оригинално отпремљене.', + 'copy_consider_attachments' => 'Прилози стране неће бити умножени.', + 'copy_consider_access' => 'Промена локације, власника или дозвола може проузроковати да садржај постане доступан онима који раније нису имали приступ.', // Conversions - 'convert_to_shelf' => 'Convert to Shelf', - 'convert_to_shelf_contents_desc' => 'You can convert this book to a new shelf with the same contents. Chapters contained within this book will be converted to new books. If this book contains any pages, that are not in a chapter, this book will be renamed and contain such pages, and this book will become part of the new shelf.', - 'convert_to_shelf_permissions_desc' => 'Any permissions set on this book will be copied to the new shelf and to all new child books that don\'t have their own permissions enforced. Note that permissions on shelves do not auto-cascade to content within, as they do for books.', - 'convert_book' => 'Convert Book', - 'convert_book_confirm' => 'Are you sure you want to convert this book?', - 'convert_undo_warning' => 'This cannot be as easily undone.', - 'convert_to_book' => 'Convert to Book', - 'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.', - 'convert_chapter' => 'Convert Chapter', - 'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?', + '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', - 'references_none' => 'There are no tracked references to this item.', - 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', + 'references' => 'Референце', + 'references_none' => 'Нема праћених референци ка овој ставки.', + 'references_to_desc' => 'Испод је наведен сав познати садржај у систему који је повезан са овом ставком.', // Watch Options - 'watch' => 'Watch', - 'watch_title_default' => 'Default Preferences', - 'watch_desc_default' => 'Revert watching to just your default notification preferences.', - 'watch_title_ignore' => 'Ignore', - 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', - 'watch_title_new' => 'New Pages', - 'watch_desc_new' => 'Notify when any new page is created within this item.', - 'watch_title_updates' => 'All Page Updates', - 'watch_desc_updates' => 'Notify upon all new pages and page changes.', - 'watch_desc_updates_page' => 'Notify upon all page changes.', - 'watch_title_comments' => 'All Page Updates & Comments', - 'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.', - 'watch_desc_comments_page' => 'Notify upon page changes and new comments.', - 'watch_change_default' => 'Change default notification preferences', - 'watch_detail_ignore' => 'Ignoring notifications', - 'watch_detail_new' => 'Watching for new pages', - 'watch_detail_updates' => 'Watching new pages and updates', - 'watch_detail_comments' => 'Watching new pages, updates & comments', - 'watch_detail_parent_book' => 'Watching via parent book', - 'watch_detail_parent_book_ignore' => 'Ignoring via parent book', - 'watch_detail_parent_chapter' => 'Watching via parent chapter', - 'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter', + '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/sr/errors.php b/lang/sr/errors.php index 55ba90a5c7a..38928ccc6e9 100644 --- a/lang/sr/errors.php +++ b/lang/sr/errors.php @@ -9,127 +9,127 @@ 'permissionJson' => 'Немате овлашћење да извршите ову акцију.', // Auth - 'error_user_exists_different_creds' => 'Корисник са е-мејл адресом :email већ постоји са другим приступним подацима.', - 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', - 'email_already_confirmed' => 'Email has already been confirmed, Try logging in.', - 'email_confirmation_invalid' => 'This confirmation token is not valid or has already been used, Please try registering again.', - 'email_confirmation_expired' => 'The confirmation token has expired, A new confirmation email has been sent.', - 'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed', - 'ldap_fail_anonymous' => 'LDAP access failed using anonymous bind', - 'ldap_fail_authed' => 'LDAP access failed using given dn & password details', - 'ldap_extension_not_installed' => 'LDAP PHP extension not installed', - 'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed', - 'saml_already_logged_in' => 'Already logged in', - 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', - 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', - 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', - 'oidc_already_logged_in' => 'Already logged in', - 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', - 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', - 'social_no_action_defined' => 'No action defined', - 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", - 'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.', - 'social_account_email_in_use' => 'The email :email is already in use. If you already have an account you can connect your :socialAccount account from your profile settings.', - 'social_account_existing' => 'This :socialAccount is already attached to your profile.', - 'social_account_already_used_existing' => 'This :socialAccount account is already used by another user.', - 'social_account_not_used' => 'This :socialAccount account is not linked to any users. Please attach it in your profile settings. ', - 'social_account_register_instructions' => 'If you do not yet have an account, You can register an account using the :socialAccount option.', - 'social_driver_not_found' => 'Social driver not found', - 'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.', - 'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.', - 'login_user_not_found' => 'A user for this action could not be found.', + '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' => 'LDAP PHP проширење није инсталирано', + '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_not_configured' => 'Ваше поставке за :socialAccount нису исправно подешене.', + 'invite_token_expired' => 'Ова веза позивнице је истекла. Можете покушати да поништите лозинку вашег налога.', + 'login_user_not_found' => 'Корисник за ову радњу није могао бити пронађен.', // System - 'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.', - 'cannot_get_image_from_url' => 'Cannot get image from :url', - 'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.', - 'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.', - 'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.', - 'uploaded' => 'The server does not allow uploads of this size. Please try a smaller file size.', + 'path_not_writable' => 'На путању :filePath није се могло отпремити. Потврдите да је уписива на серверу.', + 'cannot_get_image_from_url' => 'Није могуће добити слику из :url', + 'cannot_create_thumbs' => 'Сервер не може да прави сличице. Молим вас проверите да је GD PHP проширење инсталирано.', + 'server_upload_limit' => 'Сервер не дозвољава отпремање ове величине. Молим вас покушајте са мањом датотеком.', + 'server_post_limit' => 'Сервер не може да прими достављену количину података. Покушајте поново са мање података или са мањом датотеком.', + 'uploaded' => 'Сервер не дозвољава отпремање ове величине. Молим вас покушајте са мањом датотеком.', // Drawing & Images - 'image_upload_error' => 'An error occurred uploading the image', - 'image_upload_type_error' => 'The image type being uploaded is invalid', - 'image_upload_replace_type' => 'Image file replacements must be of the same type', - 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.', - 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.', - 'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.', - 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.', + '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 not found', - 'attachment_upload_error' => 'An error occurred uploading the attachment file', + 'attachment_not_found' => 'Прилог није пронађен', + 'attachment_upload_error' => 'Појавила се грешка током отпремања датотеке прилога', // Pages - 'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page', - 'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content', - 'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage', + 'page_draft_autosave_fail' => 'Није успело чување нацрта. Потврдите да имате везу са интернетом пре снимања ове стране', + 'page_draft_delete_fail' => 'Није успело брисање нацрта стране и добављања сачуваног садржаја', + 'page_custom_home_deletion' => 'Није могуће брисање стране док је она подешена као почетна', // Entities - 'entity_not_found' => 'Entity not found', - 'bookshelf_not_found' => 'Shelf not found', + 'entity_not_found' => 'Ентитет није пронађен', + 'bookshelf_not_found' => 'Полица није пронађена', 'book_not_found' => 'Књига није пронађена', 'page_not_found' => 'Страница није пронађена', 'chapter_not_found' => 'Поглавље није пронађено', 'selected_book_not_found' => 'Одабрана књига није пронађена', - 'selected_book_chapter_not_found' => 'The selected Book or Chapter was 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' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Корисник није могао бити направљен јер позивница е-поруком није послата', // Roles 'role_cannot_be_edited' => 'Ова улога се не може мењати', 'role_system_cannot_be_deleted' => 'Ово је системска улога и не може се мењати', - 'role_registration_default_cannot_delete' => 'This role cannot be deleted while set as the default registration role', - 'role_cannot_remove_only_admin' => 'This user is the only user assigned to the administrator role. Assign the administrator role to another user before attempting to remove it here.', + 'role_registration_default_cannot_delete' => 'Ова улога се не може обрисати док је подешена као подразумевана улога за регистрацију', + 'role_cannot_remove_only_admin' => 'Овај корисник је једини коме је додељена улога администратора. Доделите ову улогу другом кориснику пре покушаја да га уклоните овде.', // Comments - 'comment_list' => 'An error occurred while fetching the comments.', - 'cannot_add_comment_to_draft' => 'You cannot add comments to a draft.', - 'comment_add' => 'An error occurred while adding / updating the comment.', - 'comment_delete' => 'An error occurred while deleting the comment.', - 'empty_comment' => 'Cannot add an empty comment.', + 'comment_list' => 'Појавила се грешка током добављања коментара.', + 'cannot_add_comment_to_draft' => 'Не можете додати коментаре нацрту.', + 'comment_add' => 'Појавила се грешка током додавања / измене коментара.', + 'comment_delete' => 'Појавила се грешка током брисања коментара.', + 'empty_comment' => 'Није могуће додати празан коментар.', // Error pages - '404_page_not_found' => 'Page Not Found', - 'sorry_page_not_found' => 'Sorry, The page you were looking for could not be found.', - 'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.', - 'image_not_found' => 'Image Not Found', - 'image_not_found_subtitle' => 'Sorry, The image file you were looking for could not be found.', - 'image_not_found_details' => 'If you expected this image to exist it might have been deleted.', - 'return_home' => 'Return to home', + '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 is down right now', - 'back_soon' => 'It will be back up soon.', + 'app_down' => ':appName тренутно није дотупно', + 'back_soon' => 'Вратиће се ускоро.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'Није се могла прочитати ZIP датотека.', + 'import_zip_cant_decode_data' => 'НИје се могла пронаћи и декодовати ZIP data.json садржај.', + 'import_zip_no_data' => 'Подаци у ZIP датотеци немају очекивани садржај књиге, поглавља или стране.', + 'import_zip_data_too_large' => 'ZIP data.json садржај превазилази максималну величину за отпремање подешену у апликацији.', + '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' => 'No authorization token found on the request', - 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', - 'api_user_token_not_found' => 'No matching API token was found for the provided authorization token', - 'api_incorrect_token_secret' => 'The secret provided for the given used API token is incorrect', - 'api_user_no_api_permission' => 'The owner of the used API token does not have permission to make API calls', - 'api_user_token_expired' => 'The authorization token used has expired', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_no_authorization_found' => 'Није пронађен токен за ауторизацију у захтеву', + 'api_bad_authorization_format' => 'Токен за ауторизацију је пронађен у захтеву али његов формат делује неисправан', + 'api_user_token_not_found' => 'Није пронађен одговарајући API токен за достављени токен ауторизације', + 'api_incorrect_token_secret' => 'Достављена тајна за пружени коришћени API токен није исправна', + 'api_user_no_api_permission' => 'Власник коришћеног API токена нема дозволе да упућује API позиве', + 'api_user_token_expired' => 'Коришћени токен за ауторизацију је истекао', + 'api_cookie_auth_only_get' => 'Дозвољени су само GET захтеви када се користи API са аутентификацијом заснованом на колачићима', // Settings & Maintenance - 'maintenance_test_email_failure' => 'Error thrown when sending a test email:', + 'maintenance_test_email_failure' => 'Враћена је грешка током слања пробне е-поруке:', // HTTP errors - 'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts', + 'http_ssr_url_no_match' => 'Адреса се не подудара са подешеном за дозвољене SSR домаћине', ]; diff --git a/lang/sr/notifications.php b/lang/sr/notifications.php index 4cc499fdd40..f25c7038d9d 100644 --- a/lang/sr/notifications.php +++ b/lang/sr/notifications.php @@ -10,13 +10,13 @@ 'new_page_intro' => 'Нова страница је креирана у :appName:', 'updated_page_subject' => 'Ажурирана страница: :pageName', 'updated_page_intro' => 'Страница је ажурирана у :appName:', - 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', - 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', - 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', + 'updated_page_debounce' => 'Да би смо спречили масовна обавештења, неко време вам неће бити слата обавештења за измене ове стране од стране истог уредника.', + 'comment_mention_subject' => 'Поменути сте у коментару на страни: :pageName', + 'comment_mention_intro' => 'Поменути сте у коментару на :appName:', 'detail_page_name' => 'Назив странице:', 'detail_page_path' => 'Путања странице:', - 'detail_commenter' => 'Commenter:', + 'detail_commenter' => 'Коментатор:', 'detail_comment' => 'Коментар:', 'detail_created_by' => 'Креирао/ла:', 'detail_updated_by' => 'Отпремио/ла:', @@ -24,6 +24,6 @@ 'action_view_comment' => 'Погледај коментар', 'action_view_page' => 'Погледај страницу', - 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', - 'footer_reason_link' => 'your notification preferences', + 'footer_reason' => 'Ово обавештење вам је послато зато што :link покрива ове типове активности за ову ставку.', + 'footer_reason_link' => 'ваше преференце за обавештавања', ]; diff --git a/lang/sr/pagination.php b/lang/sr/pagination.php index 85bd12fc319..ce1a32bcf08 100644 --- a/lang/sr/pagination.php +++ b/lang/sr/pagination.php @@ -6,7 +6,7 @@ */ return [ - 'previous' => '« Previous', - 'next' => 'Next »', + 'previous' => '« Претходна', + 'next' => 'Следећа »', ]; diff --git a/lang/sr/passwords.php b/lang/sr/passwords.php index b408f3c2fda..1c961f6973c 100644 --- a/lang/sr/passwords.php +++ b/lang/sr/passwords.php @@ -6,10 +6,10 @@ */ return [ - 'password' => 'Passwords must be at least eight characters and match the confirmation.', - 'user' => "We can't find a user with that e-mail address.", - 'token' => 'The password reset token is invalid for this email address.', - 'sent' => 'We have e-mailed your password reset link!', - 'reset' => 'Your password has been reset!', + 'password' => 'Лозинке морају имати најмање осам карактера и да се поклапају са потврдом.', + 'user' => "Не можемо да пронађемо корисника са том адресом е-поште.", + 'token' => 'Токен за поништавање лозинке је неисправан за ову адресу е-поште.', + 'sent' => 'Послали смо вам везу за поништавање лозинке е-поштом!', + 'reset' => 'Ваша лозинка је поништена!', ]; diff --git a/lang/sr/preferences.php b/lang/sr/preferences.php index f4459d738e4..a5b885b19b4 100644 --- a/lang/sr/preferences.php +++ b/lang/sr/preferences.php @@ -5,48 +5,48 @@ */ return [ - 'my_account' => 'My Account', + 'my_account' => 'Мој налог', - 'shortcuts' => 'Shortcuts', - 'shortcuts_interface' => 'UI Shortcut Preferences', - 'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.', - 'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.', - 'shortcuts_toggle_label' => 'Keyboard shortcuts enabled', - 'shortcuts_section_navigation' => 'Navigation', - 'shortcuts_section_actions' => 'Common Actions', - 'shortcuts_save' => 'Save Shortcuts', - 'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.', - 'shortcuts_update_success' => 'Shortcut preferences have been updated!', - 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.', + 'shortcuts' => 'Пречице', + 'shortcuts_interface' => 'Подешавања пречица интерфејса', + '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' => 'Notification Preferences', - 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', - 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', - 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', - 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', - 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', - 'notifications_save' => 'Save Preferences', - 'notifications_update_success' => 'Notification preferences have been updated!', - 'notifications_watched' => 'Watched & Ignored Items', - 'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', + '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' => 'Access & Security', - 'auth_change_password' => 'Change Password', - 'auth_change_password_desc' => 'Change the password you use to log-in to the application. This must be at least 8 characters long.', - 'auth_change_password_success' => 'Password has been updated!', + 'auth' => 'Приступ и безбедност', + 'auth_change_password' => 'Промени лозинку', + 'auth_change_password_desc' => 'Промените лозинку коју користите за пријављивање у апликацију. Она мора бити дугачка најмање 8 карактера.', + 'auth_change_password_success' => 'Лозинка је ажурирана!', - 'profile' => 'Profile Details', - 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.', - 'profile_view_public' => 'View Public Profile', - 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.', - 'profile_email_desc' => 'This email will be used for notifications and, depending on active system authentication, system access.', - 'profile_email_no_permission' => 'Unfortunately you don\'t have permission to change your email address. If you want to change this, you\'d need to ask an administrator to change this for you.', - 'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.', - 'profile_admin_options' => 'Administrator Options', - 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', + '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 Account', - 'delete_my_account' => 'Delete My Account', - 'delete_my_account_desc' => 'This will fully delete your user account from the system. You will not be able to recover this account or revert this action. Content you\'ve created, such as created pages and uploaded images, will remain.', - 'delete_my_account_warning' => 'Are you sure you want to delete your account?', + 'delete_account' => 'Обриши налог', + 'delete_my_account' => 'Обриши мој налог', + 'delete_my_account_desc' => 'Ово ће у потпуности обисати ваш кориснички налог из система. Нећете моћи да повратите овај налог или да поништите ову радњу. Садржај који сте направили, као што су странице и отпремљене слике, ће остати.', + 'delete_my_account_warning' => 'Да ли заиста желите да обришете ваш налог?', ]; diff --git a/lang/sr/settings.php b/lang/sr/settings.php index 143fdaef19e..d962a358d2d 100644 --- a/lang/sr/settings.php +++ b/lang/sr/settings.php @@ -7,24 +7,24 @@ return [ // Common Messages - 'settings' => 'Подешавања', - 'settings_save' => 'Сачувај подешавања', + 'settings' => 'Поставке', + 'settings_save' => 'Сачувај поставке', 'system_version' => 'Верзија система', 'categories' => 'Категорије', // App Settings - 'app_customization' => 'Прилгођавање', + 'app_customization' => 'Прилагођавање', 'app_features_security' => 'Својства и сигурност', 'app_name' => 'Назив апликације', 'app_name_desc' => 'Ово име се приказује у заглављу и у свим системским порукама е-поште.', 'app_name_header' => 'Прикажи назив у заглављу', - 'app_public_access' => 'Javni pristup', - 'app_public_access_desc' => 'Омогућавање ове опције ће омогућити посетиоцима, који нису пријављени, да приступе садржају у вашој Боокстак инстанци.', + 'app_public_access' => 'Јавни приступ', + 'app_public_access_desc' => 'Омогућавање ове опције ће омогућити посетиоцима, који нису пријављени, да приступе садржају у вашој Букстек инстанци.', 'app_public_access_desc_guest' => 'Приступ за јавне посетиоце може се контролисати преко корисника „Гост“.', 'app_public_access_toggle' => 'Дозволи јавни приступ', 'app_public_viewing' => 'Дозволити јавно гледање?', - 'app_secure_images' => 'Веће безбедност отпремања слика', - 'app_secure_images_toggle' => 'Омогућите већу безбедност отпремања слика', + 'app_secure_images' => 'Већа безбедност при отпремању слика', + 'app_secure_images_toggle' => 'Омогући већу безбедност при отпремању слика', 'app_secure_images_desc' => 'Из разлога перформанси, све слике су јавне. Ова опција додаје насумичан низ који је тешко погодити испред Урл-ова слике. Уверите се да индекси директоријума нису омогућени да бисте спречили лак приступ.', 'app_default_editor' => 'Подразумевани уређивач страница', 'app_default_editor_desc' => 'Изаберите који уређивач ће се подразумевано користити приликом уређивања нових страница. Ово се може заменити на нивоу странице где дозволе дозвољавају.', @@ -75,36 +75,36 @@ 'reg_confirm_restrict_domain_placeholder' => 'Нема постављених ограничења', // Sorting Settings - 'sorting' => 'Lists & Sorting', - 'sorting_book_default' => 'Default Book Sort Rule', - 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', - 'sorting_rules' => 'Sort Rules', - 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', - 'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', - 'sort_rule_create' => 'Create Sort Rule', - 'sort_rule_edit' => 'Edit Sort Rule', - 'sort_rule_delete' => 'Delete Sort Rule', - 'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.', - 'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?', - 'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?', - 'sort_rule_details' => 'Sort Rule Details', - 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.', - 'sort_rule_operations' => 'Sort Operations', - 'sort_rule_operations_desc' => 'Configure the sort actions to be performed 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_available_operations' => 'Available Operations', - 'sort_rule_available_operations_empty' => 'No operations remaining', - 'sort_rule_configured_operations' => 'Configured Operations', - 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', - 'sort_rule_op_asc' => '(Asc)', - 'sort_rule_op_desc' => '(Desc)', - 'sort_rule_op_name' => 'Name - Alphabetical', - 'sort_rule_op_name_numeric' => 'Name - Numeric', - 'sort_rule_op_created_date' => 'Created Date', - 'sort_rule_op_updated_date' => 'Updated Date', - 'sort_rule_op_chapters_first' => 'Chapters First', - 'sort_rule_op_chapters_last' => 'Chapters Last', - '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' => 'Спискови и разврставање', + 'sorting_book_default' => 'Подразумевано правило разврставања', + 'sorting_book_default_desc' => 'Одаберите подразумевано правило разврставање за нове књиге. Ово неће утицати на постојеће књиге и може бити прерађено за сваку књигу.', + 'sorting_rules' => 'Правила разврставања', + 'sorting_rules_desc' => 'Ово су предефинисане операције разврставања које се могу применити на садржај у систему.', + 'sort_rule_assigned_to_x_books' => 'Додељено :count књизи|Додељено :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' => 'Одржавање', @@ -113,68 +113,68 @@ 'maint_delete_images_only_in_revisions' => 'Такође избришите слике које постоје само у старим ревизијама странице', 'maint_image_cleanup_run' => 'Покрени чишћење', 'maint_image_cleanup_warning' => ':count пронађене су потенцијално неискоришћене слике. Да ли сте сигурни да желите да избришете ове слике?', - 'maint_image_cleanup_success' => ':count potentially unused images found and deleted!', - 'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!', - 'maint_send_test_email' => 'Send a Test Email', - 'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.', - 'maint_send_test_email_run' => 'Send test email', - 'maint_send_test_email_success' => 'Email sent to :address', - 'maint_send_test_email_mail_subject' => 'Test Email', - 'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!', - 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', - 'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.', - 'maint_recycle_bin_open' => 'Open Recycle Bin', - 'maint_regen_references' => 'Regenerate References', - 'maint_regen_references_desc' => 'This action will rebuild the cross-item reference index within the database. This is usually handled automatically but this action can be useful to index old content or content added via unofficial methods.', - 'maint_regen_references_success' => 'Reference index has been regenerated!', - 'maint_timeout_command_note' => 'Note: This action can take time to run, which can lead to timeout issues in some web environments. As an alternative, this action be performed using a terminal command.', + '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' => 'Напомена: Овој радњи треба времена да се изврши, што може довести до проблема са истеком времена чекања у неким веб окружењима. Као алтернатива, ова радња се може извршити користећи команду у терминалу.', // Recycle Bin - 'recycle_bin' => 'Recycle Bin', - 'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', - 'recycle_bin_deleted_item' => 'Deleted Item', - 'recycle_bin_deleted_parent' => 'Parent', - 'recycle_bin_deleted_by' => 'Deleted By', - 'recycle_bin_deleted_at' => 'Deletion Time', - 'recycle_bin_permanently_delete' => 'Permanently Delete', - 'recycle_bin_restore' => 'Restore', - 'recycle_bin_contents_empty' => 'The recycle bin is currently empty', - 'recycle_bin_empty' => 'Empty Recycle Bin', - 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', - 'recycle_bin_destroy_list' => 'Items to be Destroyed', - 'recycle_bin_restore_list' => 'Items to be Restored', - 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.', - 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.', - 'recycle_bin_restore_parent' => 'Restore Parent', - 'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.', - 'recycle_bin_restore_notification' => 'Restored :count total items from the 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 Log', - 'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', - 'audit_event_filter' => 'Event Filter', - 'audit_event_filter_no_filter' => 'No Filter', + 'audit' => 'Запис за ревизију', + 'audit_desc' => 'Овај запис за ревизију приказује списак активности које се прате усистему. Овај списак није филтриран за разлику од сличних слискова активности у систему где су примењени филтери дозвола.', + 'audit_event_filter' => 'Филтер догађаја', + 'audit_event_filter_no_filter' => 'Без филтера', 'audit_deleted_item' => 'Избрисана ставка', - 'audit_deleted_item_name' => 'Name: :name', + 'audit_deleted_item_name' => 'Назив: :name', 'audit_table_user' => 'Корисник', 'audit_table_event' => 'Догађај', - 'audit_table_related' => 'Related Item or Detail', + 'audit_table_related' => 'Повезана ставка или детаљ', 'audit_table_ip' => 'ИП адреса', 'audit_table_date' => 'Датум активности', - 'audit_date_from' => 'Date Range From', - 'audit_date_to' => 'Date Range To', + 'audit_date_from' => 'Опсег датума од', + 'audit_date_to' => 'Опсег датума до', // Role Settings 'roles' => 'Улоге', - 'role_user_roles' => 'User Roles', - 'roles_index_desc' => 'Roles are used to group users & provide system permission to their members. When a user is a member of multiple roles the privileges granted will stack and the user will inherit all abilities.', - 'roles_x_users_assigned' => ':count user assigned|:count users assigned', - 'roles_x_permissions_provided' => ':count permission|:count permissions', - 'roles_assigned_users' => 'Assigned Users', - 'roles_permissions_provided' => 'Provided Permissions', - 'role_create' => 'Create New Role', - 'role_delete' => 'Delete Role', + 'role_user_roles' => 'Корисничке улоге', + 'roles_index_desc' => 'Улоге се користе да би се груписали корисници и доделиле дозволе за систем њиховим члановима. Када је корисник члан вишеструко група привилегије додељене ће се објединити и корисник ће наследити све способности.', + 'roles_x_users_assigned' => ':count корисник додељен|:count корисника додељено', + 'roles_x_permissions_provided' => ':count дозвола|:count дозвола', + 'roles_assigned_users' => 'Додељени корисници', + 'roles_permissions_provided' => 'Пружене дозволе', + 'role_create' => 'Направи нову улогу', + 'role_delete' => 'Обриши улогу', 'role_delete_confirm' => 'Ово ће избрисати улогу са именом \':roleName\'.', 'role_delete_users_assigned' => 'Ова улога има :userCount корисника који су јој додељени. Ако желите да мигрирате кориснике са ове улоге, изаберите нову улогу испод.', 'role_delete_no_migration' => "Немојте мигрирати кориснике", @@ -184,145 +184,145 @@ 'role_name' => 'Назив улоге', 'role_desc' => 'Кратак опис улоге', 'role_mfa_enforced' => 'Захтева вишефакторску аутентификацију', - 'role_external_auth_id' => 'External Authentication IDs', - 'role_system' => 'System Permissions', - 'role_manage_users' => 'Manage users', - 'role_manage_roles' => 'Manage roles & role permissions', - 'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions', - 'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages', - 'role_manage_page_templates' => 'Manage page templates', - 'role_access_api' => 'Access system API', - 'role_manage_settings' => 'Manage app settings', - 'role_export_content' => 'Export content', - 'role_import_content' => 'Import content', - 'role_editor_change' => 'Change page editor', - 'role_notifications' => 'Receive & manage notifications', - 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', - 'role_asset' => 'Asset Permissions', - 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', - 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', - 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', - 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', - 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', - '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', + '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' => 'Дозволе над имовином', + 'roles_system_warning' => 'Имајте на уму да приступ било којој од дозвола изнад може да дозволи кориснику да измени сопствене привилегије или привилегије других у систему. Доделите улоге са овим дозволама само корисницима од поверења.', + 'role_asset_desc' => 'Ове дозволе конторлишу подразумевани приступ имовини унутар система. Дозволе над књигама, поглављима и странама ће прерадити ове дозволе.', + 'role_asset_admins' => 'Администраторима је аутоматски дат приступ целом садржају али ове опције могу да прикажу или сакрију опције на интерфејсу.', + 'role_asset_image_view_note' => 'Ово се односи на видљивост унутар менаџера слика. Сам приступ отпремљеним сликама зависиће од системских опције складиштења слика.', + 'role_asset_users_note' => 'Ове дозволе ће технички такође омогућити видљивост и претрагу корисника у систему.', + 'role_all' => 'Све', + 'role_own' => 'Власник', + 'role_controlled_by_asset' => 'Контролисано по имовини у којој су постављене', + 'role_controlled_by_page_delete' => 'Контролисано дозволама брисања од странице', + 'role_save' => 'Сачувај улогу', + 'role_users' => 'Корисници са овом улогом', + 'role_users_none' => 'Тренутно ниједном кориснику није додељена ова улога', // Users - 'users' => 'Users', - 'users_index_desc' => 'Create & manage individual user accounts within the system. User accounts are used for login and attribution of content & activity. Access permissions are primarily role-based but user content ownership, among other factors, may also affect permissions & access.', - 'user_profile' => 'User Profile', - 'users_add_new' => 'Add New User', - 'users_search' => 'Search Users', - 'users_latest_activity' => 'Latest Activity', - 'users_details' => 'User Details', - 'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.', - 'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.', - 'users_role' => 'User Roles', - 'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.', - 'users_password' => 'User Password', - 'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.', - 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', - 'users_send_invite_option' => 'Send user invite email', - 'users_external_auth_id' => 'External Authentication ID', - 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', - 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', - 'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.', - 'users_delete' => 'Delete User', - 'users_delete_named' => 'Delete user :userName', - 'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.', - 'users_delete_confirm' => 'Are you sure you want to delete this user?', - 'users_migrate_ownership' => 'Migrate Ownership', - 'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.', - 'users_none_selected' => 'No user selected', - 'users_edit' => 'Edit User', - 'users_edit_profile' => 'Edit Profile', - 'users_avatar' => 'User Avatar', - 'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.', - 'users_preferred_language' => 'Preferred Language', - 'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.', - 'users_social_accounts' => 'Social Accounts', - 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', - 'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.', - 'users_social_connect' => 'Connect Account', - 'users_social_disconnect' => 'Disconnect Account', - 'users_social_status_connected' => 'Connected', - 'users_social_status_disconnected' => 'Disconnected', - 'users_social_connected' => ':socialAccount account was successfully attached to your profile.', - 'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.', - 'users_api_tokens' => 'API Tokens', - 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', - 'users_api_tokens_none' => 'No API tokens have been created for this user', - 'users_api_tokens_create' => 'Create Token', - 'users_api_tokens_expires' => 'Expires', - 'users_api_tokens_docs' => 'API Documentation', - 'users_mfa' => 'Multi-Factor Authentication', - '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 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?', + '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' => 'Овај корисник представља сваког госта који посећује вашу инстанцу. Не може се користити за пријављивање али је аутоматски додељен.', + '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' => 'Налози друштвених мрежа', + 'users_social_accounts_desc' => 'Погледајте статус повезаних налога друштвених мрежа за овог корисника. Налози друштвених мрежа се могу користити за приступ систему поред примарног система за аутентификацију.', + 'users_social_accounts_info' => 'Овде можете да повежете ваше друге налоге за бржу и лакшу пријаву. Развезивање налога овде не укида претходно ауторизован приступ. Повуците приступ из поставки вашег профила на повезаном налогу друштвене мреже.', + '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 токени', + 'users_api_tokens_desc' => 'Направљај и управљај токенима за приступ који се користе за аутентификацију са BookStack REST API-јем. Дозволама за API се управља кроз корисника којем припада токен.', + 'users_api_tokens_none' => 'Још ниједан API токен није направљен за овог корисника', + 'users_api_tokens_create' => 'Направи токен', + 'users_api_tokens_expires' => 'Истиче', + 'users_api_tokens_docs' => 'API документација', + 'users_mfa' => 'Вишефакторска аутентификација', + 'users_mfa_desc' => 'Подесите вишефакторску аутентификацију ка додатни слој безбедности за ваш кориснички налог.', + 'users_mfa_x_methods' => ':count начин подешен|:count начина су подешена', + 'users_mfa_configure' => 'Подеси начине', + 'users_mfa_reset' => 'Поништи начине вишефакторске аутентификације', + 'users_mfa_reset_desc' => 'Ово ће понитити и почистити све подешене начине вишефакторске аутентификације за овог корисника. Ако је вишефакторска аутентификација неопходна за било коју од његових улога, од њих ће бити затражено да подесе нови начин приликом наредне пријаве.', + 'users_mfa_reset_confirm' => 'Да ли заиста желите да поништите вишефакторску аутентификацију за овог корисника?', // API Tokens - 'user_api_token_create' => 'Create API Token', - 'user_api_token_name' => 'Name', - 'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.', - 'user_api_token_expiry' => 'Expiry Date', - 'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.', - 'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.', - 'user_api_token' => 'API Token', - 'user_api_token_id' => 'Token ID', - 'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.', - 'user_api_token_secret' => 'Token Secret', - 'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.', - 'user_api_token_created' => 'Token created :timeAgo', - 'user_api_token_updated' => 'Token updated :timeAgo', - 'user_api_token_delete' => 'Delete Token', - 'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.', - 'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?', + 'user_api_token_create' => 'Направи API токен', + 'user_api_token_name' => 'Назив', + 'user_api_token_name_desc' => 'Дајте вашем токену читљив назив као будући подсетник његове сврхе.', + 'user_api_token_expiry' => 'Датум истека', + 'user_api_token_expiry_desc' => 'Подесите датум када истиче ваш токен. Након овог датума, захтеви послати користећи овај токен више неће функционисати. Остављањем овог поља празним ће подесити истек 100 година у будућности.', + 'user_api_token_create_secret_message' => 'Моментално након прављења овог токена "ID токена" и "Тајна токена" ће бити генерисани и приказани. Тајна ће бити приказана само једном зато се потрудите да ископирате вредност на неко сигурно и безбедно место пре настављања.', + 'user_api_token' => 'API токен', + 'user_api_token_id' => 'ID токена', + 'user_api_token_id_desc' => 'Ово је неизменљиви идентификатор који је генерисао систем за овај токен којег треба доставити у API захтевима.', + 'user_api_token_secret' => 'Тајна токена', + 'user_api_token_secret_desc' => 'Ово је тајна коју је генерисао систем за овај токен коју треба доставити у API захтевима. Ово ће бити приказано само једном зато ископирајте ову вредност на неко сигурно и безбедно место.', + 'user_api_token_created' => 'Токен је направљен :timeAgo', + 'user_api_token_updated' => 'Токен је ажуриран :timeAgo', + 'user_api_token_delete' => 'Обриши токен', + 'user_api_token_delete_warning' => 'Ово ће у потпуности обрисати овај API токен са називом \':tokenName\' из система.', + 'user_api_token_delete_confirm' => 'Да ли заиста желите да обришете овај API токен?', // Webhooks - 'webhooks' => 'Webhooks', - 'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.', - 'webhooks_x_trigger_events' => ':count trigger event|:count trigger events', - 'webhooks_create' => 'Create New Webhook', - 'webhooks_none_created' => 'No webhooks have yet been created.', - 'webhooks_edit' => 'Edit Webhook', - 'webhooks_save' => 'Save Webhook', - 'webhooks_details' => 'Webhook Details', - 'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.', - 'webhooks_events' => 'Webhook Events', - 'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.', - 'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.', - 'webhooks_events_all' => 'All system events', - 'webhooks_name' => 'Webhook Name', - 'webhooks_timeout' => 'Webhook Request Timeout (Seconds)', - 'webhooks_endpoint' => 'Webhook Endpoint', - 'webhooks_active' => 'Webhook Active', - 'webhook_events_table_header' => 'Events', - 'webhooks_delete' => 'Delete Webhook', - 'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.', - 'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?', - 'webhooks_format_example' => 'Webhook Format Example', - 'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.', - 'webhooks_status' => 'Webhook Status', - 'webhooks_last_called' => 'Last Called:', - 'webhooks_last_errored' => 'Last Errored:', - 'webhooks_last_error_message' => 'Last Error Message:', + 'webhooks' => 'Веб закачке', + 'webhooks_index_desc' => 'Веб закачке су начин да се пошаљу подаци на екстерне УРЛ адресе када се одређене радње и догађаји одвију унутар система који дозвољава интеграцију засновану на догађајима са екстерним платформама као што су размена порука или системи за обавештавање.', + 'webhooks_x_trigger_events' => ':count окидач догађаја|:count окидача догађаја', + 'webhooks_create' => 'Направи нову веб закачку', + 'webhooks_none_created' => 'Још нису направљене веб закачке.', + 'webhooks_edit' => 'Измени веб закачку', + 'webhooks_save' => 'Сачувај веб закачку', + 'webhooks_details' => 'Детаљи веб закачке', + 'webhooks_details_desc' => 'Пружите одговарајући назив и POST крајњу тачку којој слати податке ове веб закачке.', + 'webhooks_events' => 'Догађаји веб закачке', + 'webhooks_events_desc' => 'Изаберите све догађаје који требају да окину позив за ову веб закачку.', + 'webhooks_events_warning' => 'Имајте на уму да ће ови догађаји бити окинути за све изабране догађаје, чак и када су примењене прилагођене дозволе. Обезбедите да коришћење ове веб закачке неће изложити поверљив садржај.', + 'webhooks_events_all' => 'Сви догађаји система', + 'webhooks_name' => 'Назив веб закачке', + 'webhooks_timeout' => 'Време чекања на веб закачку (секунде)', + 'webhooks_endpoint' => 'Крајња тачка веб закачке', + 'webhooks_active' => 'Веб закачка је активна', + 'webhook_events_table_header' => 'Догађаји', + 'webhooks_delete' => 'Обриши веб закачку', + 'webhooks_delete_warning' => 'Ово ће у потпуности обрисати ову веб закачку са називом \':webhookName\' из система.', + 'webhooks_delete_confirm' => 'Да ли заиста желите да обришете ову веб закачку?', + 'webhooks_format_example' => 'Пример формата веб закачке', + 'webhooks_format_example_desc' => 'Податак веб закачке се шаље као POST захтев подешеној крајњој тачки као JSON пратећи формат испод. "related_item" и "url" својства су опциона и зависиће од типе окинутог догађаја.', + 'webhooks_status' => 'Статус веб закачке', + 'webhooks_last_called' => 'Последњи пут позвана:', + 'webhooks_last_errored' => 'Последња грешка:', + 'webhooks_last_error_message' => 'Порука последње грешке:', // Licensing - 'licenses' => 'Licenses', - 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', - 'licenses_bookstack' => 'BookStack License', - 'licenses_php' => 'PHP Library Licenses', - 'licenses_js' => 'JavaScript Library Licenses', - 'licenses_other' => 'Other Licenses', - 'license_details' => 'License Details', + '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. @@ -366,8 +366,9 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', - 'th' => 'ภาษาไทย', + 'th' => 'Тајландски', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/sr/validation.php b/lang/sr/validation.php index 6770c5a8015..35e6c706f93 100644 --- a/lang/sr/validation.php +++ b/lang/sr/validation.php @@ -8,113 +8,113 @@ return [ // Standard laravel validation lines - 'accepted' => 'The :attribute must be accepted.', - 'active_url' => 'The :attribute is not a valid URL.', - 'after' => 'The :attribute must be a date after :date.', - 'alpha' => 'The :attribute may only contain letters.', - 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', - 'alpha_num' => 'The :attribute may only contain letters and numbers.', + 'accepted' => ':attribute мора бити прихваћен.', + 'active_url' => ':attribute није исправна URL адреса.', + 'after' => ':attribute мора бити датум после :date.', + 'alpha' => ':attribute може да садржи само слова.', + 'alpha_dash' => ':attribute може да садржи само слова, бројеве, црте и подцрте.', + 'alpha_num' => ':attribute може да садржи само слова и бројеве.', 'array' => ':attribute мора бити низ.', - 'backup_codes' => 'The provided code is not valid or has already been used.', - 'before' => 'The :attribute must be a date before :date.', + 'backup_codes' => 'Достављени код није исправан или је већ искоришћен.', + 'before' => ':attribute мора бити датум пре :date.', 'between' => [ - 'numeric' => 'The :attribute must be between :min and :max.', - 'file' => 'The :attribute must be between :min and :max kilobytes.', - 'string' => 'The :attribute must be between :min and :max characters.', - 'array' => 'The :attribute must have between :min and :max items.', + 'numeric' => ':attribute мора бити између :min и :max.', + 'file' => ':attribute мора бити између :min и :max килобајта.', + 'string' => ':attribute мора бити између :min и :max карактера.', + 'array' => ':attribute мора бити између :min и :max ставки.', ], - 'boolean' => 'The :attribute field must be true or false.', - 'confirmed' => 'The :attribute confirmation does not match.', - 'date' => 'The :attribute is not a valid date.', - 'date_format' => 'The :attribute does not match the format :format.', - 'different' => 'The :attribute and :other must be different.', - 'digits' => 'The :attribute must be :digits digits.', - 'digits_between' => 'The :attribute must be between :min and :max digits.', - 'email' => 'The :attribute must be a valid email address.', - 'ends_with' => 'The :attribute must end with one of the following: :values', - 'file' => 'The :attribute must be provided as a valid file.', - 'filled' => 'The :attribute field is required.', + 'boolean' => 'Поље :attribute мора бити тачно или нетачно.', + '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' => 'The :attribute must be greater than :value.', - 'file' => 'The :attribute must be greater than :value kilobytes.', - 'string' => 'The :attribute must be greater than :value characters.', - 'array' => 'The :attribute must have more than :value items.', + 'numeric' => ':attribute мора бити веће од :value.', + 'file' => ':attribute мора бити веће од :value килобајта.', + 'string' => ':attribute мора бити веће од :value карактера.', + 'array' => ':attribute мора садржати више од :value ставки.', ], 'gte' => [ - 'numeric' => 'The :attribute must be greater than or equal :value.', - 'file' => 'The :attribute must be greater than or equal :value kilobytes.', - 'string' => 'The :attribute must be greater than or equal :value characters.', - 'array' => 'The :attribute must have :value items or more.', + 'numeric' => ':attribute мора бити веће од или једнако :value.', + 'file' => ':attribute мора бити веће од или једнако :value килобајта.', + 'string' => ':attribute мора бити веће од или једнако :value карактера.', + 'array' => ':attribute мора да садржи :value или више ставки.', ], - 'exists' => 'The selected :attribute is invalid.', - 'image' => 'The :attribute must be an image.', - 'image_extension' => 'The :attribute must have a valid & supported image extension.', - 'in' => 'The selected :attribute is invalid.', - 'integer' => 'The :attribute must be an integer.', - 'ip' => 'The :attribute must be a valid IP address.', - 'ipv4' => 'The :attribute must be a valid IPv4 address.', - 'ipv6' => 'The :attribute must be a valid IPv6 address.', - 'json' => 'The :attribute must be a valid JSON string.', + 'exists' => 'Изабрани :attribute је неисправан.', + 'image' => ':attribute мора бити слика.', + 'image_extension' => ':attribute мора да има исправну и подржану екстензију слике.', + 'in' => 'Изабрани :attribute је неисправан.', + 'integer' => ':attribute мора бити цели број.', + 'ip' => ':attribute мора бити исправна ИП адреса.', + 'ipv4' => ':attribute мора бити исправна IPv4 адреса.', + 'ipv6' => ':attribute мора бити исправна IPv6 адреса.', + 'json' => ':attribute мора бити исправна JSON ниска.', 'lt' => [ - 'numeric' => 'The :attribute must be less than :value.', - 'file' => 'The :attribute must be less than :value kilobytes.', - 'string' => 'The :attribute must be less than :value characters.', - 'array' => 'The :attribute must have less than :value items.', + 'numeric' => ':attribute мора бити мање од :value.', + 'file' => ':attribute мора бити мање од :value килобајта.', + 'string' => ':attribute мора бити мање од :value карактера.', + 'array' => ':attribute мора садржати мање од :value ставки.', ], 'lte' => [ - 'numeric' => 'The :attribute must be less than or equal :value.', - 'file' => 'The :attribute must be less than or equal :value kilobytes.', - 'string' => 'The :attribute must be less than or equal :value characters.', - 'array' => 'The :attribute must not have more than :value items.', + 'numeric' => ':attribute мора бити мање од или једнако :value.', + 'file' => ':attribute мора бити мање од или једнако :value килобајта.', + 'string' => ':attribute мора бити мање од или једнако :value карактера.', + 'array' => ':attribute не сме садржати више од :value ставки.', ], 'max' => [ - 'numeric' => 'The :attribute may not be greater than :max.', - 'file' => 'The :attribute may not be greater than :max kilobytes.', - 'string' => 'The :attribute may not be greater than :max characters.', - 'array' => 'The :attribute may not have more than :max items.', + 'numeric' => ':attribute не може бити већи од :max.', + 'file' => ':attribute не може бити већи од :max килобајта.', + 'string' => ':attribute не може бити већи од :max знакова.', + 'array' => ':attribute не може садржати више од :max ставки.', ], - 'mimes' => 'The :attribute must be a file of type: :values.', + 'mimes' => ':attribute мора бити датотека типа: :values.', 'min' => [ - 'numeric' => 'The :attribute must be at least :min.', - 'file' => 'The :attribute must be at least :min kilobytes.', - 'string' => 'The :attribute must be at least :min characters.', - 'array' => 'The :attribute must have at least :min items.', + 'numeric' => ':attribute мора бити најмање :min.', + 'file' => ':attribute мора бити најмање :min килобајта.', + 'string' => ':attribute мора бити најмање :min карактера.', + 'array' => ':attribute мора садржати најмање :min ставки.', ], - 'not_in' => 'The selected :attribute is invalid.', - 'not_regex' => 'The :attribute format is invalid.', - 'numeric' => 'The :attribute must be a number.', - 'regex' => 'The :attribute format is invalid.', - 'required' => 'The :attribute field is required.', - 'required_if' => 'The :attribute field is required when :other is :value.', - 'required_with' => 'The :attribute field is required when :values is present.', - 'required_with_all' => 'The :attribute field is required when :values is present.', - 'required_without' => 'The :attribute field is required when :values is not present.', - 'required_without_all' => 'The :attribute field is required when none of :values are present.', - 'same' => 'The :attribute and :other must match.', - 'safe_url' => 'The provided link may not be safe.', + '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' => 'The :attribute must be :size.', - 'file' => 'The :attribute must be :size kilobytes.', - 'string' => 'The :attribute must be :size characters.', - 'array' => 'The :attribute must contain :size items.', + 'numeric' => ':attribute мора бити :size.', + 'file' => ':attribute мора бити :size килобајта.', + 'string' => ':attribute мора бити :size карактера.', + 'array' => ':attribute мора да садржи :size ставки.', ], - 'string' => 'The :attribute must be a string.', - 'timezone' => 'The :attribute must be a valid zone.', - 'totp' => 'The provided code is not valid or has expired.', - 'unique' => 'The :attribute has already been taken.', - 'url' => 'The :attribute format is invalid.', - 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'string' => ':attribute мора бити текст.', + 'timezone' => ':attribute мора бити исправна зона.', + 'totp' => 'Достављени код није исправан или је истекао.', + 'unique' => ':attribute је већ заузет.', + '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 MB.', + 'zip_file_mime' => ':attribute мора бити референца за датотеку типа :validTypes, пронађено :foundType.', + 'zip_model_expected' => 'Очекиван је објекат податка али је ":type" пронађен.', + 'zip_unique' => ':attribute мора бити јединствено за тип објекта унутар ZIP-а.', // Custom validation lines 'custom' => [ 'password-confirm' => [ - 'required_with' => 'Password confirmation required', + 'required_with' => 'Неопходна је потврда лозинке', ], ], diff --git a/lang/sv/settings.php b/lang/sv/settings.php index e900a86a3ba..bf9968270ab 100644 --- a/lang/sv/settings.php +++ b/lang/sv/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenska', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/th/settings.php b/lang/th/settings.php index 558d2da0f6e..af85e37c109 100644 --- a/lang/th/settings.php +++ b/lang/th/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/tk/settings.php b/lang/tk/settings.php index d03024a89d6..0e5ce84cf21 100644 --- a/lang/tk/settings.php +++ b/lang/tk/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/tr/settings.php b/lang/tr/settings.php index 39ae23f2845..ac7ba9747ae 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovence', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/uk/settings.php b/lang/uk/settings.php index 521dc9578fd..0eae5a364c7 100644 --- a/lang/uk/settings.php +++ b/lang/uk/settings.php @@ -366,8 +366,9 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', - 'th' => 'ภาษาไทย', + 'th' => 'Тайська', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/uz/settings.php b/lang/uz/settings.php index c1d3b3d46e1..ed3421c914c 100644 --- a/lang/uz/settings.php +++ b/lang/uz/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/vi/settings.php b/lang/vi/settings.php index d029fe7f329..a3d3d549155 100644 --- a/lang/vi/settings.php +++ b/lang/vi/settings.php @@ -366,6 +366,7 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', diff --git a/lang/zh_CN/auth.php b/lang/zh_CN/auth.php index 4c97b46ce02..6cb68aad8ee 100644 --- a/lang/zh_CN/auth.php +++ b/lang/zh_CN/auth.php @@ -8,7 +8,7 @@ 'failed' => '用户名或密码错误。', 'throttle' => '您的登录次数过多,请在:seconds秒后重试。', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => '多因子认证尝试次数过多,请在 :seconds 秒后重试。', // Login & Register 'sign_up' => '注册', diff --git a/lang/zh_CN/entities.php b/lang/zh_CN/entities.php index 9daf816da04..bede7fb1c0d 100644 --- a/lang/zh_CN/entities.php +++ b/lang/zh_CN/entities.php @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => '具备相关权限的用户可在 “列表与排序” 设置区域中创建自动排序选项规则。', 'books_sort_named' => '排序书籍「:bookName」', 'books_sort_name' => '按名称排序', 'books_sort_created' => '创建时间排序', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => '切换侧边栏', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => '页面内容', + 'page_contents_none' => '未在页面内容中找到任何标题。', + 'page_contents_info' => '目录是根据页面中使用的所有标题格式生成的。', 'page_tags' => '页面标签', 'chapter_tags' => '章节标签', 'book_tags' => '书籍标签', diff --git a/lang/zh_CN/settings.php b/lang/zh_CN/settings.php index eef14b1687d..c1fae2dce47 100644 --- a/lang/zh_CN/settings.php +++ b/lang/zh_CN/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => '章节正序', 'sort_rule_op_chapters_last' => '章节倒序', '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.', + 'sorting_page_limits_desc' => '设置系统内各列表的单页显示数量。通常较少的数据量性能更佳,较多的数量则能减少用户的翻页操作。建议设置为 6 的倍数。', // Maintenance settings 'maint' => '维护', @@ -207,7 +207,7 @@ 'role_all' => '全部的', 'role_own' => '拥有的', 'role_controlled_by_asset' => '由其所在的资源来控制', - 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', + 'role_controlled_by_page_delete' => '受页面删除权限限制', 'role_save' => '保存角色', 'role_users' => '此角色的用户', 'role_users_none' => '目前没有用户被分配到这个角色', @@ -366,8 +366,9 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', - 'th' => 'ภาษาไทย', + 'th' => '泰语', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', diff --git a/lang/zh_TW/activities.php b/lang/zh_TW/activities.php index 791fa26b752..ac1524ec433 100644 --- a/lang/zh_TW/activities.php +++ b/lang/zh_TW/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => '使用者已成功更新。', 'user_delete' => '已刪除使用者', 'user_delete_notification' => '使用者移除成功', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => '重設使用者的多要素驗證', + 'user_mfa_reset_notification' => '重設多要素驗證方法', // API Tokens 'api_token_create' => '建立 API 權杖', diff --git a/lang/zh_TW/auth.php b/lang/zh_TW/auth.php index 47e8ed950b2..7ac341e16b0 100644 --- a/lang/zh_TW/auth.php +++ b/lang/zh_TW/auth.php @@ -8,7 +8,7 @@ 'failed' => '使用者名稱或密碼錯誤。', 'throttle' => '您的登入次數過多,請在 :seconds 秒後重試。', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => '多要素驗證嘗試次數過多。請於 :seconds 秒後再試。', // Login & Register 'sign_up' => '註冊', diff --git a/lang/zh_TW/entities.php b/lang/zh_TW/entities.php index ba53d885dd4..4c5cfed1de2 100644 --- a/lang/zh_TW/entities.php +++ b/lang/zh_TW/entities.php @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => '具備相關權限的使用者可在「清單與排序」設定區域中建立自動排序選項規則。', 'books_sort_named' => '排序書本 :bookName', 'books_sort_name' => '按名稱排序', 'books_sort_created' => '按建立時間排序', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => '切換側邊欄', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => '頁面內容', + 'page_contents_none' => '在頁面內容中未找到任何標題。', + 'page_contents_info' => '內容選單是根據頁面中使用的任何標題格式所產生的。', 'page_tags' => '頁面標籤', 'chapter_tags' => '章節標籤', 'book_tags' => '書本標籤', diff --git a/lang/zh_TW/errors.php b/lang/zh_TW/errors.php index e5c08ca141b..989ab0ad04f 100644 --- a/lang/zh_TW/errors.php +++ b/lang/zh_TW/errors.php @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => '給定使用的 API 權杖的密碼錯誤', 'api_user_no_api_permission' => '使用的 API 權杖擁有者無權呼叫 API', 'api_user_token_expired' => '使用的授權權杖已過期', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => '使用以 cookie 為基礎的驗證來呼叫 API 時,僅允許 GET 請求', // Settings & Maintenance 'maintenance_test_email_failure' => '寄送測試電子郵件時發生錯誤:', diff --git a/lang/zh_TW/settings.php b/lang/zh_TW/settings.php index 95108545019..e12f2caf5c6 100644 --- a/lang/zh_TW/settings.php +++ b/lang/zh_TW/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => '第一章', 'sort_rule_op_chapters_last' => '最後一章', '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.', + 'sorting_page_limits_desc' => '設定系統內各清單每頁顯示的項目數量。通常項目數量較少時效能較佳,而數量較多則可避免使用者需點擊多頁瀏覽。建議採用 6 的倍數。', // Maintenance settings 'maint' => '維護', @@ -208,7 +208,7 @@ 'role_all' => '全部', 'role_own' => '擁有', 'role_controlled_by_asset' => '依據隸屬的資源來決定', - 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', + 'role_controlled_by_page_delete' => '受頁面刪除權限控制', 'role_save' => '儲存角色', 'role_users' => '屬於此角色的使用者', 'role_users_none' => '目前沒有使用者被分配到此角色', @@ -265,9 +265,9 @@ 'users_mfa_desc' => '設定多重身份驗證為您的帳戶多增加了一道防線', 'users_mfa_x_methods' => ':count 個措施已配置|:count 個措施已配置', 'users_mfa_configure' => '方式設置', - '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?', + 'users_mfa_reset' => '重設多要素驗證方式', + 'users_mfa_reset_desc' => '此操作將重設並清除該使用者所有已設定的多要素驗證方法。若其任何角色要求使用多要素驗證,系統將在下次登入時提示其設定新的驗證方法。', + 'users_mfa_reset_confirm' => '您確定要重設此使用者的多要素驗證嗎?', // API Tokens 'user_api_token_create' => '建立 API 權杖', @@ -367,8 +367,9 @@ 'ru' => 'Русский', 'sk' => 'Slovensky', 'sl' => 'Slovenščina', + 'sr' => 'Српски', 'sv' => 'Svenska', - 'th' => 'ภาษาไทย', + 'th' => '泰語', 'tr' => 'Türkçe', 'uk' => 'Українська', 'uz' => 'O‘zbekcha', From ad283ef0ed91d5d70f392a78b62d973d491d3240 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 2 Jul 2026 09:41:17 +0100 Subject: [PATCH 279/398] Updated packages, translators and licensing pre v26.05.2 --- .github/translators.txt | 2 + composer.lock | 414 +++++++++++++------------ dev/licensing/php-library-licenses.txt | 2 +- 3 files changed, 211 insertions(+), 207 deletions(-) diff --git a/.github/translators.txt b/.github/translators.txt index 6aa2be470f3..a2f6a957066 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -546,3 +546,5 @@ lonestan :: Russian Paul Kernstock (kernstock) :: German brtbr :: German; German Informal Ricardo Covelo (covelo12) :: Portuguese +Bojan Maksimovic (PolarniMeda) :: Serbian (Cyrillic) +Dian Prawira (wiradian84) :: Indonesian diff --git a/composer.lock b/composer.lock index e0726839914..685d88f524d 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.384.5", + "version": "3.387.1", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "c7d34f2d60515bd0c307e462268f75877842da4a" + "reference": "2e1a16e9c87f2a069aa8a0a14a314dd148de21e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c7d34f2d60515bd0c307e462268f75877842da4a", - "reference": "c7d34f2d60515bd0c307e462268f75877842da4a", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2e1a16e9c87f2a069aa8a0a14a314dd148de21e1", + "reference": "2e1a16e9c87f2a069aa8a0a14a314dd148de21e1", "shasum": "" }, "require": { @@ -82,7 +82,7 @@ "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/promises": "^2.0", "guzzlehttp/psr7": "^2.4.5", - "mtdowling/jmespath.php": "^2.8.0", + "mtdowling/jmespath.php": "^2.9.1", "php": ">=8.1", "psr/http-message": "^1.0 || ^2.0", "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" @@ -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.384.5" + "source": "https://github.com/aws/aws-sdk-php/tree/3.387.1" }, - "time": "2026-06-08T18:25:02+00:00" + "time": "2026-07-01T18:10:42+00:00" }, { "name": "bacon/bacon-qr-code", @@ -982,16 +982,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.5", + "version": "v7.1.0", "source": { "type": "git", "url": "https://github.com/googleapis/php-jwt.git", - "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", - "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", "shasum": "" }, "require": { @@ -1000,6 +1000,7 @@ "require-dev": { "guzzlehttp/guzzle": "^7.4", "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", @@ -1008,7 +1009,8 @@ }, "suggest": { "ext-sodium": "Support EdDSA (Ed25519) signatures", - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" }, "type": "library", "autoload": { @@ -1033,16 +1035,16 @@ } ], "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/firebase/php-jwt", + "homepage": "https://github.com/googleapis/php-jwt", "keywords": [ "jwt", "php" ], "support": { "issues": "https://github.com/googleapis/php-jwt/issues", - "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" }, - "time": "2026-04-01T20:38:03+00:00" + "time": "2026-06-11T17:54:14+00:00" }, { "name": "fruitcake/php-cors", @@ -1179,26 +1181,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.11.1", + "version": "7.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c" + "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c", - "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/55901a76dfd2006a0cc012b9e3c5b487f796478d", + "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.11", + "guzzlehttp/psr7": "^2.12.3", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1207,7 +1209,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.5", + "guzzlehttp/test-server": "^0.6", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1287,7 +1289,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.11.1" + "source": "https://github.com/guzzle/guzzle/tree/7.13.1" }, "funding": [ { @@ -1303,7 +1305,7 @@ "type": "tidelift" } ], - "time": "2026-06-07T22:54:06+00:00" + "time": "2026-06-29T20:14:18+00:00" }, { "name": "guzzlehttp/promises", @@ -1391,16 +1393,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.11.0", + "version": "2.12.3", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", "shasum": "" }, "require": { @@ -1409,7 +1411,7 @@ "psr/http-message": "^1.1 || ^2.0", "ralouphie/getallheaders": "^3.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -1490,7 +1492,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.0" + "source": "https://github.com/guzzle/psr7/tree/2.12.3" }, "funding": [ { @@ -1506,25 +1508,25 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:30:48+00:00" + "time": "2026-06-23T15:21:08+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.6", + "version": "v1.0.8", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" + "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", - "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/9c19128923b05a5d7355e5d2318d7808b7e33bbd", + "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -1576,7 +1578,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.8" }, "funding": [ { @@ -1592,7 +1594,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T22:00:21+00:00" + "time": "2026-06-23T13:02:23+00:00" }, { "name": "intervention/gif", @@ -1807,16 +1809,16 @@ }, { "name": "laravel/framework", - "version": "v12.61.1", + "version": "v12.62.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "e8472ca9774452fe50841d9bdced060679f4d58d" + "reference": "f7e61eb1e0e06a38996802b769bce9127aec227c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d", - "reference": "e8472ca9774452fe50841d9bdced060679f4d58d", + "url": "https://api.github.com/repos/laravel/framework/zipball/f7e61eb1e0e06a38996802b769bce9127aec227c", + "reference": "f7e61eb1e0e06a38996802b769bce9127aec227c", "shasum": "" }, "require": { @@ -2025,20 +2027,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-06-04T14:22:52+00:00" + "time": "2026-06-09T13:50:13+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.18", + "version": "v0.3.21", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + "reference": "7753c65c281c2550c7c183f14e18062073b7d821" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", - "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", + "reference": "7753c65c281c2550c7c183f14e18062073b7d821", "shasum": "" }, "require": { @@ -2082,9 +2084,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.18" + "source": "https://github.com/laravel/prompts/tree/v0.3.21" }, - "time": "2026-05-19T00:47:18+00:00" + "time": "2026-06-26T00:11:25+00:00" }, { "name": "laravel/serializable-closure", @@ -2149,16 +2151,16 @@ }, { "name": "laravel/socialite", - "version": "v5.27.0", + "version": "v5.28.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e" + "reference": "4c131ff4b24d8881a9c8fe4eecb5ffeff9803f26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", - "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e", + "url": "https://api.github.com/repos/laravel/socialite/zipball/4c131ff4b24d8881a9c8fe4eecb5ffeff9803f26", + "reference": "4c131ff4b24d8881a9c8fe4eecb5ffeff9803f26", "shasum": "" }, "require": { @@ -2217,7 +2219,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-04-24T14:05:47+00:00" + "time": "2026-06-12T03:24:05+00:00" }, { "name": "laravel/tinker", @@ -2476,16 +2478,16 @@ }, { "name": "league/flysystem", - "version": "3.34.0", + "version": "3.35.1", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", - "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f23af6c5aafd958a7593029a271d77baf5ed793c", + "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c", "shasum": "" }, "require": { @@ -2553,22 +2555,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.1" }, - "time": "2026-05-14T10:28:08+00:00" + "time": "2026-06-25T06:52:23+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.34.0", + "version": "3.35.1", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8" + "reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/0c62fdac907791d8649ad3c61cb7a77628344fb8", - "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94", + "reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94", "shasum": "" }, "require": { @@ -2608,9 +2610,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.34.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.1" }, - "time": "2026-05-04T08:24:00+00:00" + "time": "2026-06-25T06:51:08+00:00" }, { "name": "league/flysystem-local", @@ -3131,16 +3133,16 @@ }, { "name": "masterminds/html5", - "version": "2.10.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fcf91eb64359852f00d921887b219479b4f21251" + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", - "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", "shasum": "" }, "require": { @@ -3148,7 +3150,7 @@ "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { @@ -3192,9 +3194,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" }, - "time": "2025-07-25T09:04:22+00:00" + "time": "2026-06-23T18:43:15+00:00" }, { "name": "monolog/monolog", @@ -3301,16 +3303,16 @@ }, { "name": "mtdowling/jmespath.php", - "version": "2.8.0", + "version": "2.9.1", "source": { "type": "git", "url": "https://github.com/jmespath/jmespath.php.git", - "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + "reference": "9c208ba27ae7d90853c288b3795d6702eb251d34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", - "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9c208ba27ae7d90853c288b3795d6702eb251d34", + "reference": "9c208ba27ae7d90853c288b3795d6702eb251d34", "shasum": "" }, "require": { @@ -3319,7 +3321,7 @@ }, "require-dev": { "composer/xdebug-handler": "^3.0.3", - "phpunit/phpunit": "^8.5.33" + "phpunit/phpunit": "^8.5.52" }, "bin": [ "bin/jp.php" @@ -3327,7 +3329,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.8-dev" + "dev-master": "2.9-dev" } }, "autoload": { @@ -3361,22 +3363,22 @@ ], "support": { "issues": "https://github.com/jmespath/jmespath.php/issues", - "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.1" }, - "time": "2024-09-04T18:46:31+00:00" + "time": "2026-06-11T10:43:56+00:00" }, { "name": "nesbot/carbon", - "version": "3.11.4", + "version": "3.13.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", - "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", "shasum": "" }, "require": { @@ -3468,7 +3470,7 @@ "type": "tidelift" } ], - "time": "2026-04-07T09:57:54+00:00" + "time": "2026-06-18T13:49:15+00:00" }, { "name": "nette/schema", @@ -4033,16 +4035,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.52", + "version": "3.0.55", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" + "reference": "db9744e6d47e742b1f974e965ad49bdd041105af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", - "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", + "reference": "db9744e6d47e742b1f974e965ad49bdd041105af", "shasum": "" }, "require": { @@ -4123,7 +4125,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" }, "funding": [ { @@ -4139,7 +4141,7 @@ "type": "tidelift" } ], - "time": "2026-04-27T07:02:15+00:00" + "time": "2026-06-14T23:24:10+00:00" }, { "name": "pragmarx/google2fa", @@ -4195,16 +4197,16 @@ }, { "name": "predis/predis", - "version": "v3.5.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "8cc4319c06924c8ff0c5c7eec4243a19e3be32f1" + "reference": "5c996db191ee2d9bafe651f454b1fca16754271b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/8cc4319c06924c8ff0c5c7eec4243a19e3be32f1", - "reference": "8cc4319c06924c8ff0c5c7eec4243a19e3be32f1", + "url": "https://api.github.com/repos/predis/predis/zipball/5c996db191ee2d9bafe651f454b1fca16754271b", + "reference": "5c996db191ee2d9bafe651f454b1fca16754271b", "shasum": "" }, "require": { @@ -4246,7 +4248,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v3.5.0" + "source": "https://github.com/predis/predis/tree/v3.5.1" }, "funding": [ { @@ -4254,7 +4256,7 @@ "type": "github" } ], - "time": "2026-06-02T19:25:56+00:00" + "time": "2026-06-11T16:56:53+00:00" }, { "name": "psr/clock", @@ -4670,16 +4672,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.23", + "version": "v0.12.24", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", - "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", "shasum": "" }, "require": { @@ -4743,9 +4745,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" }, - "time": "2026-05-23T13:41:31+00:00" + "time": "2026-06-29T15:41:09+00:00" }, { "name": "ralouphie/getallheaders", @@ -4869,20 +4871,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.2", + "version": "4.9.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": ">=0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4941,9 +4943,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2025-12-14T04:43:48+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { "name": "robrichards/xmlseclibs", @@ -4989,16 +4991,16 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v9.3.0", + "version": "v9.4.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", - "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", "shasum": "" }, "require": { @@ -5009,15 +5011,15 @@ "require-dev": { "php-parallel-lint/php-parallel-lint": "1.4.0", "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.32 || 2.1.32", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", "phpunit/phpunit": "8.5.52", "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.2.8", - "rector/type-perfect": "1.0.0 || 2.1.0", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", "squizlabs/php_codesniffer": "4.0.1", - "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -5025,7 +5027,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.4.x-dev" + "dev-main": "9.5.x-dev" } }, "autoload": { @@ -5063,9 +5065,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" }, - "time": "2026-03-03T17:31:43+00:00" + "time": "2026-06-18T15:10:53+00:00" }, { "name": "socialiteproviders/discord", @@ -5515,16 +5517,16 @@ }, { "name": "symfony/console", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "shasum": "" }, "require": { @@ -5589,7 +5591,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.13" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -5609,7 +5611,7 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:56:14+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/css-selector", @@ -5682,16 +5684,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -5729,7 +5731,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -5749,20 +5751,20 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + "reference": "4e1a093b481f323e6e326451f9760c3868430673" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", + "reference": "4e1a093b481f323e6e326451f9760c3868430673", "shasum": "" }, "require": { @@ -5811,7 +5813,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.8" + "source": "https://github.com/symfony/error-handler/tree/v7.4.14" }, "funding": [ { @@ -5831,20 +5833,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.9", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", - "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", "shasum": "" }, "require": { @@ -5896,7 +5898,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.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" }, "funding": [ { @@ -5916,20 +5918,20 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-06-06T11:10:32+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -5976,7 +5978,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -5996,7 +5998,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", @@ -6070,16 +6072,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e0be088d22278583a82da281886e8c3592fbf149" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", - "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { @@ -6114,7 +6116,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.8" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -6134,20 +6136,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "bc354f47c62301e990b7874fa662326368508e2c" + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", - "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", "shasum": "" }, "require": { @@ -6196,7 +6198,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.13" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" }, "funding": [ { @@ -6216,20 +6218,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:20:33+00:00" + "time": "2026-06-11T07:31:44+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "9df847980c436451f4f51d1284491bb4356dd989" + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", - "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", "shasum": "" }, "require": { @@ -6315,7 +6317,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.13" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" }, "funding": [ { @@ -6335,20 +6337,20 @@ "type": "tidelift" } ], - "time": "2026-05-27T08:31:43+00:00" + "time": "2026-06-27T09:14:35+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", "shasum": "" }, "require": { @@ -6399,7 +6401,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.12" + "source": "https://github.com/symfony/mailer/tree/v7.4.14" }, "funding": [ { @@ -6419,7 +6421,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-13T08:51:35+00:00" }, { "name": "symfony/mime", @@ -7491,16 +7493,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -7554,7 +7556,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -7574,7 +7576,7 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", @@ -7669,16 +7671,16 @@ }, { "name": "symfony/translation", - "version": "v7.4.10", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde" + "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/ada7578c30dd5feaa8259cff3e885069ea81ddde", - "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde", + "url": "https://api.github.com/repos/symfony/translation/zipball/a1af4dacb24eb7ef4f1ca71b94da8ddbce572281", + "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281", "shasum": "" }, "require": { @@ -7745,7 +7747,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.10" + "source": "https://github.com/symfony/translation/tree/v7.4.14" }, "funding": [ { @@ -7765,20 +7767,20 @@ "type": "tidelift" } ], - "time": "2026-05-06T11:19:24+00:00" + "time": "2026-06-06T09:33:19+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -7827,7 +7829,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -7847,7 +7849,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/uid", @@ -7929,16 +7931,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", "shasum": "" }, "require": { @@ -7992,7 +7994,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" }, "funding": [ { @@ -8012,7 +8014,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:44:50+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "thecodingmachine/safe", @@ -9183,11 +9185,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.2", + "version": "2.2.3", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", - "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4048833dd47b377287818841877fb3087289509c", + "reference": "4048833dd47b377287818841877fb3087289509c", "shasum": "" }, "require": { @@ -9243,7 +9245,7 @@ "type": "github" } ], - "time": "2026-06-05T09:00:01+00:00" + "time": "2026-06-30T21:15:26+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt index 2259c44c301..0c30fd5bb14 100644 --- a/dev/licensing/php-library-licenses.txt +++ b/dev/licensing/php-library-licenses.txt @@ -110,7 +110,7 @@ License: BSD-3-Clause License File: vendor/firebase/php-jwt/LICENSE Copyright: Copyright (c) 2011, Neuman Vong Source: https://github.com/googleapis/php-jwt.git -Link: https://github.com/firebase/php-jwt +Link: https://github.com/googleapis/php-jwt ----------- fruitcake/php-cors License: MIT From 6107161275b3594e17847d9f5362b9786fa8bf09 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 2 Jul 2026 10:21:01 +0100 Subject: [PATCH 280/398] URLs: Fixed issue in comparisons when elements missing --- app/Util/UrlComparison.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Util/UrlComparison.php b/app/Util/UrlComparison.php index 66984918d29..9fda2d6142b 100644 --- a/app/Util/UrlComparison.php +++ b/app/Util/UrlComparison.php @@ -18,9 +18,9 @@ public function originsMatch(): bool $aParts = parse_url($this->a); $bParts = parse_url($this->b); - return $aParts['host'] === $bParts['host'] - && $aParts['scheme'] === $bParts['scheme'] - && $aParts['port'] === $bParts['port']; + return ($aParts['host'] ?? '') === ($bParts['host'] ?? '') + && ($aParts['scheme'] ?? '') === ($bParts['scheme'] ?? '') + && ($aParts['port'] ?? '') === ($bParts['port'] ?? ''); } /** From f0f5b62c9df8c94a0e16e8162778bd35f7a05e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrizio=20Volont=C3=A9?= Date: Mon, 13 Jul 2026 15:41:36 +0200 Subject: [PATCH 281/398] Audit Log: Applied display timezone to dates Uses the shared date formatter for audit log activity dates so APP_DISPLAY_TIMEZONE is respected while APP_TIMEZONE can remain UTC for storage. Related to issue #4786 and PR #5790, which introduced APP_DISPLAY_TIMEZONE and updated most in-app absolute date displays but left the audit log table rendering the raw created_at value. --- resources/views/settings/audit.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 0407275e059..ce6a0463427 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -115,7 +115,7 @@ class="mr-xs hide-over-m">{{ trans('settings.audit_table_ip') }} :
    {{ $activity->ip }}
    {{ trans('settings.audit_table_date') }} - :
    {{ $activity->created_at }}
    + :
    {{ $dates->absolute($activity->created_at) }} @endforeach From 16e60b39398476e1963cf3922da7137fe65b004b Mon Sep 17 00:00:00 2001 From: Mesar Hameed Date: Thu, 16 Jul 2026 06:32:57 +0000 Subject: [PATCH 282/398] Views: Added aria-labels to custom checkbox components for screen readers The custom checkbox components (custom-checkbox.blade.php and toggle-switch.blade.php) use a as the visual/interactive element while hiding the native with display:none. The span had no accessible name, so screen readers would announce "checkbox" without any label context. Added aria-label to the role="checkbox" span in both templates. Also added optional parameter to custom-checkbox that falls back to when not provided, allowing callers to supply a more descriptive accessible name. --- resources/views/form/custom-checkbox.blade.php | 2 ++ resources/views/form/toggle-switch.blade.php | 1 + resources/views/settings/roles/parts/checkbox.blade.php | 3 ++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/views/form/custom-checkbox.blade.php b/resources/views/form/custom-checkbox.blade.php index 7750c6f0007..736baf52119 100644 --- a/resources/views/form/custom-checkbox.blade.php +++ b/resources/views/form/custom-checkbox.blade.php @@ -3,11 +3,13 @@ $value $checked $label +$ariaLabel? (optional, falls back to $label) --}} \ No newline at end of file diff --git a/resources/views/form/toggle-switch.blade.php b/resources/views/form/toggle-switch.blade.php index 375eda3d7b5..32ce1092c25 100644 --- a/resources/views/form/toggle-switch.blade.php +++ b/resources/views/form/toggle-switch.blade.php @@ -3,6 +3,7 @@ @icon('check') {{ $label }} \ No newline at end of file diff --git a/resources/views/settings/roles/parts/checkbox.blade.php b/resources/views/settings/roles/parts/checkbox.blade.php index 44cdd22bdf6..5818888d723 100644 --- a/resources/views/settings/roles/parts/checkbox.blade.php +++ b/resources/views/settings/roles/parts/checkbox.blade.php @@ -3,5 +3,6 @@ 'name' => 'permissions[' . $permission . ']', 'value' => 'true', 'checked' => old('permissions'.$permission, false)|| (!old('display_name', false) && (isset($role) && $role->hasPermission($permission))), - 'label' => $label + 'label' => $label, + 'ariaLabel' => $ariaLabel ?? null ]) \ No newline at end of file From 9a242ca44968e0f572dc1b25023c3fc3d8f3aff6 Mon Sep 17 00:00:00 2001 From: Mesar Hameed Date: Thu, 16 Jul 2026 06:33:56 +0000 Subject: [PATCH 283/398] Views: Added translatable aria-labels for role permission checkboxes Role permission checkboxes (shelves, books, chapters, pages, revisions, images, attachments) used short labels like 'Own' and 'All' for their aria-labels. Screen readers would announce these without any context about what resource or action the permission applies to. Added 'role_permission_aria' translation key with the pattern ':action :resource: :value' and updated all permission row templates to use trans() with parameters. This gives translators full control over word order and separators while providing screen readers with descriptive labels like 'Create books: All'. --- lang/en/settings.php | 1 + .../parts/asset-permissions-row.blade.php | 26 ++++++++++++------- .../related-asset-permissions-row.blade.php | 17 +++++++----- .../parts/revisions-permissions-row.blade.php | 3 ++- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/lang/en/settings.php b/lang/en/settings.php index 0e5ce84cf21..ac483f8fd10 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -206,6 +206,7 @@ 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', + 'role_permission_aria' => ':action :resource: :value', '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', diff --git a/resources/views/settings/roles/parts/asset-permissions-row.blade.php b/resources/views/settings/roles/parts/asset-permissions-row.blade.php index 8bff570cb85..200153b1b71 100644 --- a/resources/views/settings/roles/parts/asset-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/asset-permissions-row.blade.php @@ -6,27 +6,35 @@
    {{ trans('common.create') }}
    @if($permissionPrefix === 'page' || $permissionPrefix === 'chapter') - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-own', 'label' => trans('settings.role_own')]) + @php $createOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.create'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $createOwnAria])
    @endif - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => trans('settings.role_all')]) + @php $createAllAria = trans('settings.role_permission_aria', ['action' => trans('common.create'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $createAllAria])
    {{ trans('common.view') }}
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-own', 'label' => trans('settings.role_own')]) + @php $viewOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.view'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $viewOwnAria])
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')]) + @php $viewAllAria = trans('settings.role_permission_aria', ['action' => trans('common.view'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $viewAllAria])
    {{ trans('common.edit') }}
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own')]) + @php $editOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $editOwnAria])
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all')]) + @php $editAllAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $editAllAria])
    {{ trans('common.delete') }}
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own')]) + @php $deleteOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $deleteOwnAria])
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all')]) + @php $deleteAllAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $deleteAllAria])
    - \ No newline at end of file + diff --git a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php index b9bf5427f83..ac8e7422e43 100644 --- a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php @@ -5,7 +5,8 @@
    {{ trans('common.create') }}
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => '']) + @php $createAllAria = trans('settings.role_permission_aria', ['action' => trans('common.create'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => '', 'ariaLabel' => $createAllAria]) @if($permissionPrefix === 'comment')2@endif
    @@ -14,16 +15,20 @@
    {{ trans('common.edit') }}
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own')]) + @php $editOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $editOwnAria]) @if($permissionPrefix === 'comment')2@endif
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all')]) + @php $editAllAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $editAllAria]) @if($permissionPrefix === 'comment')2@endif
    {{ trans('common.delete') }}
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own')]) + @php $deleteOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $deleteOwnAria])
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all')]) + @php $deleteAllAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $deleteAllAria])
    - \ No newline at end of file + 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 326925ef93c..50b714de468 100644 --- a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php @@ -9,7 +9,8 @@
    {{ trans('common.view') }}
    - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')]) + @php $viewAllAria = trans('settings.role_permission_aria', ['action' => trans('common.view'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $viewAllAria])
    {{ trans('common.edit') }}
    From 4e406c41c4c8060a5795e74c66fb96362e54f400 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 23 Jul 2026 20:57:48 +0100 Subject: [PATCH 284/398] Attachments: Aligned update form permission checks with other actions Thanks to Ashutosh Jena(MAVERICK-VF142) for reporting. Not considered a significant security issue since it already required page update permissions, which would generally be considered higher privileged than the added page view. --- .../Controllers/AttachmentController.php | 3 ++- tests/Uploads/AttachmentTest.php | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index aa9e0e29195..5e128bc3466 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -93,8 +93,9 @@ public function getUpdateForm(string $attachmentId) /** @var Attachment $attachment */ $attachment = Attachment::query()->findOrFail($attachmentId); + $this->checkOwnablePermission(Permission::PageView, $attachment->page); $this->checkOwnablePermission(Permission::PageUpdate, $attachment->page); - $this->checkOwnablePermission(Permission::AttachmentCreate, $attachment); + $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment); return view('attachments.manager-edit-form', [ 'attachment' => $attachment, diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 3f8a2aaf14f..30e45fff6fc 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -274,6 +274,26 @@ public function test_attachment_access_without_permission_shows_404() $this->files->deleteAllAttachmentFiles(); } + public function test_attachment_edit_form_access_requires_view_permission() + { + $page = $this->entities->page(); + /** @var Attachment $attachment */ + $attachment = Attachment::factory()->create(['uploaded_to' => $page->id]); + $editor = $this->users->editor(); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->grantUserRolePermissions($editor, [Permission::AttachmentUpdateAll]); + $this->permissions->setEntityPermissionsForRole($page, ['update'], $editor->roles()->first()); + + $resp = $this->actingAs($editor)->get("/attachments/edit/{$attachment->id}"); + $this->assertPermissionError($resp); + + $this->permissions->setEntityPermissionsForRole($page, ['view', 'update'], $editor->roles()->first()); + $resp = $this->actingAs($editor)->get("/attachments/edit/{$attachment->id}"); + $resp->assertOk(); + $resp->assertSee($attachment->name); + } + public function test_data_and_js_links_cannot_be_attached_to_a_page() { $page = $this->entities->page(); From 0ada5d2dc9a0cf4a637d48cd659f7d0c20e50ad5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 24 Jul 2026 14:33:47 +0100 Subject: [PATCH 285/398] Login: Added extra timing defenses for failed login attempts - Adds a dummy hash attempt to balance the time of unknown user login attempt with known user login attempt to help prevent timing being used to indicate existing accounts. - Adds some random variance to failed login attempts to help prevent timing based information discovery. Thanks to Tanner Marks for their responsible disclosure of this. --- app/Access/LoginService.php | 15 +++++++++++++- tests/Auth/AuthTest.php | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/app/Access/LoginService.php b/app/Access/LoginService.php index 769290c1648..6547926c559 100644 --- a/app/Access/LoginService.php +++ b/app/Access/LoginService.php @@ -13,6 +13,7 @@ use BookStack\Theming\ThemeEvents; use BookStack\Users\Models\User; use Exception; +use Illuminate\Support\Facades\Hash; class LoginService { @@ -171,10 +172,22 @@ public function attempt(array $credentials, string $method, bool $remember = fal } catch (LoginAttemptInvalidUserException $e) { // Catch and return false for non-login accounts // so it looks like a normal invalid login. - return false; + $result = false; } } + // Perform a dummy hash check to balance out the time of a login with an existing known user + // with that of a user not in the system (which we don't perform a hash check for in the above). + if (!$result && auth()->getLastAttempted() === null) { + Hash::check($credentials['password'], '$2y$04$A.H9icXH4/lxLd9DHuaYqO/GVBd0OKetxyY0txmNfTAlPLVnTBx3y'); + } + + // Add some noise to request times on failed login attempts + if (!$result) { + $sleepMs = random_int(0, 250); + usleep($sleepMs * 1000); + } + return $result; } diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index b42f7cb40d6..4cd793fdff3 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -3,6 +3,7 @@ namespace Tests\Auth; use BookStack\Access\Mfa\MfaSession; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; use Illuminate\Testing\TestResponse; use Tests\TestCase; @@ -172,6 +173,44 @@ public function test_login_specifically_disabled_for_guest_account() $resp->assertSee('These credentials do not match our records.'); } + public function test_failed_login_attempt_has_noise_added_and_have_similar_times_between_known_and_unknown_users() + { + $this->markTestSkipped('Time consuming test'); + + $user = $this->users->editor(); + $user->password = bcrypt('password'); + $user->save(); + // Warmup + $this->post('/login', ['email' => $user->email, 'password' => 'passwordtesting']); + + // For known user attempts + $durations = []; + for ($i = 0; $i < 25; $i++) { + $knownStart = microtime(true); + $this->post('/login', ['email' => $user->email, 'password' => 'passwordtesting']); + $durations[] = (microtime(true) - $knownStart) * 1000; + Cache::clear(); // Clear the cache to avoid hitting rate limits + } + $range = max($durations) - min($durations); + $this->assertGreaterThan(125, $range); + $knownAvg = array_sum($durations) / count($durations); + + // For unknown user attempts + $durations = []; + for ($i = 0; $i < 25; $i++) { + $unknownStart = microtime(true); + $this->post('/login', ['email' => 'unknown@example.com', 'password' => 'passwordtesting']); + $durations[] = (microtime(true) - $unknownStart) * 1000; + Cache::clear(); // Clear the cache to avoid hitting rate limits + } + $range = max($durations) - min($durations); + $this->assertGreaterThan(125, $range); + $unknownAvg = array_sum($durations) / count($durations); + + $knownDiff = abs($knownAvg - $unknownAvg); + $this->assertLessThan(25, $knownDiff); + } + /** * Perform a login. */ From cb195999ebbaae17ac98733712d7acae3a45e42c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Jul 2026 12:12:07 +0100 Subject: [PATCH 286/398] Content Filter: Updated srcset and URI handling - Added more complex srcset parsing and URI handling via custom built HTMLPurifier filter, which I've also provided upstream. - Added a custom URI filter to force require URI schemes. --- .../HtmlPurifier/ConfiguredHtmlPurifier.php | 11 +- .../HtmlPurifier/Filters/UriEnsureScheme.php | 39 ++++ app/Util/HtmlPurifier/SrcsetAttrDef.php | 201 +++++++++++++++++- tests/Entity/PageContentFilteringTest.php | 29 +++ 4 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 app/Util/HtmlPurifier/Filters/UriEnsureScheme.php diff --git a/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php index 221db6ffc39..c4dd91778c6 100644 --- a/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php +++ b/app/Util/HtmlPurifier/ConfiguredHtmlPurifier.php @@ -3,6 +3,7 @@ namespace BookStack\Util\HtmlPurifier; use BookStack\App\AppVersion; +use BookStack\Util\HtmlPurifier\Filters\UriEnsureScheme; use BookStack\Util\HtmlPurifier\Filters\UriLimitFileProtocolToAnchors; use BookStack\Util\UrlFilter; use HTMLPurifier; @@ -84,14 +85,18 @@ protected function setConfig(HTMLPurifier_Config $config, string $cachePath): vo $config->set('Attr.EnableID', true); $config->set('Attr.ID.HTML5', true); $config->set('Output.FixInnerHTML', false); - $config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%'); $allowedSchemes = UrlFilter::getAllowedSchemes(); $allowedSchemesSetting = []; foreach ($allowedSchemes as $scheme) { $allowedSchemesSetting[$scheme] = true; } + $defaultScheme = str_starts_with(url('/'), 'http:') ? 'http' : 'https'; + $config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%'); $config->set('URI.AllowedSchemes', $allowedSchemesSetting); + $config->set('URI.MakeAbsolute', true); + $config->set('URI.DefaultScheme', $defaultScheme); + $config->set('URI.Base', url('/')); // $config->set('Cache.DefinitionImpl', null); // Disable cache during testing } @@ -155,7 +160,8 @@ protected function configureHtmlDefinition(HTMLPurifier_HTMLDefinition $definiti // Allow mention-ids on links $definition->addAttribute('a', 'data-mention-user-id', 'Number'); - // Set up custom handler for srcset to limit accepted types + // Set up custom handler for srcset + // To remove once added upstream: https://github.com/xemlock/htmlpurifier-html5/pull/91 $definition->addAttribute('img', 'srcset', new SrcsetAttrDef()); $definition->addAttribute('source', 'srcset', new SrcsetAttrDef()); } @@ -163,6 +169,7 @@ protected function configureHtmlDefinition(HTMLPurifier_HTMLDefinition $definiti protected function configureUriDefinition(HTMLPurifier_URIDefinition $definition): void { $definition->registerFilter(new UriLimitFileProtocolToAnchors()); + $definition->registerFilter(new UriEnsureScheme()); } public function purify(string $html): string diff --git a/app/Util/HtmlPurifier/Filters/UriEnsureScheme.php b/app/Util/HtmlPurifier/Filters/UriEnsureScheme.php new file mode 100644 index 00000000000..6b4651b69c3 --- /dev/null +++ b/app/Util/HtmlPurifier/Filters/UriEnsureScheme.php @@ -0,0 +1,39 @@ +getDefinition('URI'); + $defaultScheme = $def->defaultScheme ?? ''; + + if (empty($uri->scheme) && $defaultScheme) { + $uri->scheme = $defaultScheme; + } + + return true; + } +} diff --git a/app/Util/HtmlPurifier/SrcsetAttrDef.php b/app/Util/HtmlPurifier/SrcsetAttrDef.php index 3b8417a55e2..2f0ffffab30 100644 --- a/app/Util/HtmlPurifier/SrcsetAttrDef.php +++ b/app/Util/HtmlPurifier/SrcsetAttrDef.php @@ -3,24 +3,209 @@ namespace BookStack\Util\HtmlPurifier; use HTMLPurifier_AttrDef; +use HTMLPurifier_AttrDef_URI; /** - * Custom attribute definition to filter out potentially dangerous - * values from the srcset attribute. + * A custom attribute definition for handling Srcset attributes. + * Has been provided upstream via: + * https://github.com/xemlock/htmlpurifier-html5/pull/91 + * but awaiting review/merge. */ class SrcsetAttrDef extends HTMLPurifier_AttrDef { public function validate($string, $config, $context) { - $lower = strtolower($string); - $nonAllowed = ['javascript:', 'vbscript:', 'data:', 'file:']; + $sources = $this->parseImageSources($string); + if (empty($sources)) { + return false; + } + + $uriFilter = new HTMLPurifier_AttrDef_URI(true); + + $filtered = array(); + foreach ($sources as $source) { + $uri = $source['uri']; + $descriptor = $source['descriptor']; + $validatedUri = $uriFilter->validate($uri, $config, $context); + if (is_string($validatedUri)) { + if ($descriptor) { + $filtered[] = $validatedUri . ' ' . $source['descriptor']; + } else { + $filtered[] = $validatedUri; + } + } + } + + if (empty($filtered)) { + return false; + } + + return implode(', ', $filtered); + } + + /** + * Parse the image source from srcset attribute text. + * Returns false if it's found to be invalid, otherwise + * returns an array of uri and descriptor combinations. + * + * This aims to follow the WHATWG parsing spec as per: + * https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute + * + * @param string $string + * @return array{uri: string, descriptor: string}[]|false + */ + private function parseImageSources($string) + { + $imageSources = array(); + $asciiWhitespace = " \n\r\t\f"; + $asciiWhiteSpaceComma = $asciiWhitespace . ','; + $input = trim($string, $asciiWhiteSpaceComma); + + if ($input === "") { + return false; + } + + $position = 0; + while ($position < strlen($input)) { + $position += strspn($input, $asciiWhitespace, $position); + $urlEnd = $position + strcspn($input, $asciiWhitespace, $position); + $url = substr($input, $position, $urlEnd - $position); + $position = $urlEnd; + $descriptors = array(); + + if (strpos($url, ',') === strlen($url) - 1) { + $url = rtrim($url, ','); + } else { + $position += strspn($input, $asciiWhitespace, $position); + $currentDescriptor = ''; + $state = 'in_descriptor'; + while (true) { + if ($position < strlen($input)) { + $c = $input[$position]; + } else { + $c = null; + } + + if ($state === 'in_descriptor') { + if ($c !== null && strpos($asciiWhitespace, $c) !== false) { + if ($currentDescriptor !== '') { + $descriptors[] = $currentDescriptor; + } + $state = 'after_descriptor'; + } else if ($c === ',') { + $position++; + if ($currentDescriptor !== '') { + $descriptors[] = $currentDescriptor; + } + break; + } else if ($c === '(') { + $currentDescriptor .= $c; + $state = 'in_parens'; + } else if ($c === null) { + if ($currentDescriptor !== '') { + $descriptors[] = $currentDescriptor; + } + break; + } else { + $currentDescriptor .= $c; + } + } else if ($state === 'in_parens') { + if ($c === ')') { + $currentDescriptor .= $c; + $state = 'in_descriptor'; + } else if ($c === null) { + $descriptors[] = $currentDescriptor; + break; + } else { + $currentDescriptor .= $c; + } + } else if ($state === 'after_descriptor') { + if ($c !== null && strpos($asciiWhitespace, $c) !== false) { + // Stay in this state + } else if ($c === null) { + break; + } else { + $state = 'in_descriptor'; + $position--; + } + } + + $position++; + } + } + + $descriptor = $this->formatDescriptor($descriptors); - foreach ($nonAllowed as $nonAllowedString) { - if (str_contains($lower, $nonAllowedString)) { - return false; + if ($url && $descriptor !== false) { + $imageSources[] = array( + 'uri' => $url, + 'descriptor' => $descriptor, + ); } } - return $string; + return $imageSources; + } + + /** + * Parse and format a single descriptor from an array of potential + * descriptor strings. Returns empty if valid but no descriptor. + * Returns false if invalid. + * @param string[] $descriptors + * @return false|string + */ + private function formatDescriptor(array $descriptors) + { + $error = false; + $width = ''; + $density = ''; + $futureCompatH = ''; + + foreach ($descriptors as $descriptor) { + $descriptor = trim($descriptor); + if ($descriptor === '') { + continue; + } + + $unit = $descriptor[strlen($descriptor) - 1]; + $number = trim(substr($descriptor, 0, -1)); + + if ($unit === 'w' && filter_var($number, FILTER_VALIDATE_INT) && intval($number) >= 0) { + if (!empty($width) || !empty($density) || intval($number) === 0) { + $error = true; + } + $width = $number; + } else if ($unit === 'x' && filter_var($number, FILTER_VALIDATE_FLOAT)) { + if (!empty($width) || !empty($density) || !empty($futureCompatH) || floatval($number) < 0) { + $error = true; + } + $density = $number; + } else if ($unit === 'h' && filter_var($number, FILTER_VALIDATE_INT) && intval($number) >= 0) { + if (!empty($futureCompatH) || !empty($density)) { + $error = true; + } + $futureCompatH = $number; + } else { + $error = true; + } + } + + if (!empty($futureCompatH) && empty($width)) { + $error = true; + } + + if ($error) { + return false; + } + + if ($width) { + return $width . 'w'; + } + + if ($density) { + return $density . 'x'; + } + + return ''; } } diff --git a/tests/Entity/PageContentFilteringTest.php b/tests/Entity/PageContentFilteringTest.php index 5cb898e05d4..68b597387f2 100644 --- a/tests/Entity/PageContentFilteringTest.php +++ b/tests/Entity/PageContentFilteringTest.php @@ -488,6 +488,35 @@ public function test_allow_list_style_filtering() } } + public function test_media_protocol_relative_urls_are_given_scheme_depending_on_app_url() + { + $testCasesExpectedByInput = [ + '
    My local image
    ' => '
    My local image
    ', + '' => '', + '' => '', + '' => '', + '
    My local image
    ' => '
    My local image
    ', + ]; + + $baseUrls = ['https://example.com' => 'https', 'http://example.com' => 'http']; + foreach ($baseUrls as $baseUrl => $expectedScheme) { + $this->runWithEnv(['APP_URL' => $baseUrl], function () use ($expectedScheme, $baseUrl, $testCasesExpectedByInput) { + config()->set('app.content_filtering', 'a'); + $page = $this->entities->page(); + $this->asEditor(); + + foreach ($testCasesExpectedByInput as $input => $expected) { + $page->html = $input; + $page->save(); + $resp = $this->get($page->getUrl()); + + $resp->assertSee(str_replace('SCHEME', $expectedScheme, $expected), false); + $resp->assertDontSee($input, false); + } + }); + } + } + public function test_allow_list_does_not_filter_cases() { $testCasesExpectedByInput = [ From 1732f15e4ccbfcb6bd11a62e2676b3f0bea26d30 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Jul 2026 21:23:47 +0100 Subject: [PATCH 287/398] External Auth: Updated user external auth id matching to be stricter Added tests to cover. Thanks to whale120 for reporting. --- app/Access/ExternalBaseUserProvider.php | 10 ++++-- app/Access/RegistrationService.php | 4 +-- app/App/Providers/AuthServiceProvider.php | 3 +- app/Users/UserRepo.php | 19 ++++++++++++ ...pdate_users_external_auth_id_collation.php | 31 +++++++++++++++++++ tests/Auth/LdapTest.php | 30 ++++++++++++++++++ tests/Auth/OidcTest.php | 23 +++++++++++++- tests/Auth/Saml2Test.php | 23 ++++++++++++++ 8 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2026_07_27_201402_update_users_external_auth_id_collation.php diff --git a/app/Access/ExternalBaseUserProvider.php b/app/Access/ExternalBaseUserProvider.php index 2165fd4591e..ef001289d11 100644 --- a/app/Access/ExternalBaseUserProvider.php +++ b/app/Access/ExternalBaseUserProvider.php @@ -3,11 +3,17 @@ namespace BookStack\Access; use BookStack\Users\Models\User; +use BookStack\Users\UserRepo; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\UserProvider; class ExternalBaseUserProvider implements UserProvider { + public function __construct( + protected UserRepo $userRepo, + ) { + } + /** * Retrieve a user by their unique identifier. */ @@ -44,9 +50,7 @@ public function updateRememberToken(Authenticatable $user, $token) */ public function retrieveByCredentials(array $credentials): ?Authenticatable { - return User::query() - ->where('external_auth_id', $credentials['external_auth_id']) - ->first(); + return $this->userRepo->getByExternalAuthId($credentials['external_auth_id']); } /** diff --git a/app/Access/RegistrationService.php b/app/Access/RegistrationService.php index e47479e7991..e3ae9b2025c 100644 --- a/app/Access/RegistrationService.php +++ b/app/Access/RegistrationService.php @@ -52,9 +52,7 @@ protected function registrationAllowed(): bool */ public function findOrRegister(string $name, string $email, string $externalId): User { - $user = User::query() - ->where('external_auth_id', '=', $externalId) - ->first(); + $user = $this->userRepo->getByExternalAuthId($externalId); if (is_null($user)) { $userData = [ diff --git a/app/App/Providers/AuthServiceProvider.php b/app/App/Providers/AuthServiceProvider.php index 6a816252131..8c71fee3ac3 100644 --- a/app/App/Providers/AuthServiceProvider.php +++ b/app/App/Providers/AuthServiceProvider.php @@ -10,6 +10,7 @@ use BookStack\Access\RegistrationService; use BookStack\Api\ApiTokenGuard; use BookStack\Users\Models\User; +use BookStack\Users\UserRepo; use Illuminate\Support\Facades\Auth; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -60,7 +61,7 @@ public function boot(): void public function register(): void { Auth::provider('external-users', function () { - return new ExternalBaseUserProvider(); + return new ExternalBaseUserProvider($this->app[UserRepo::class]); }); // Bind and provide the default system user as a singleton to the app instance when needed. diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php index 2c0897ceffb..1643756c8d1 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -51,6 +51,25 @@ public function getBySlug(string $slug): User return User::query()->where('slug', '=', $slug)->firstOrFail(); } + /** + * Get a user by their external auth ID value. + * Returns null if no matching user found. + */ + public function getByExternalAuthId(string $externalId): User|null + { + // We only really expect at most one user from the search, but as an extra layer of defence against database + // normalisation we search possible matches exactly against the value. + $users = User::query()->where('external_auth_id', '=', $externalId)->get(); + + foreach ($users as $user) { + if ($user->external_auth_id === $externalId) { + return $user; + } + } + + return null; + } + /** * Create a new basic instance of user with the given pre-validated data. * diff --git a/database/migrations/2026_07_27_201402_update_users_external_auth_id_collation.php b/database/migrations/2026_07_27_201402_update_users_external_auth_id_collation.php new file mode 100644 index 00000000000..9f9267c8800 --- /dev/null +++ b/database/migrations/2026_07_27_201402_update_users_external_auth_id_collation.php @@ -0,0 +1,31 @@ +string('external_auth_id') + ->collation('utf8mb4_bin') + ->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->string('external_auth_id') + ->change(); + }); + } +}; diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index d1f128a50d9..5adbe11a1e9 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -207,6 +207,36 @@ public function test_a_custom_uid_attribute_can_be_specified_and_is_used_properl $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']); } + public function test_login_uses_exact_match_for_external_auth_id_values() + { + config()->set(['services.ldap.id_attribute' => 'my_custom_id']); + // External auth id the same as we expect below but different casing + User::query()->forceCreate([ + 'email' => 'otheruser@example.com', + 'external_auth_id' => 'CoolUser456', + 'email_confirmed' => true, + 'name' => 'Barry Scott', + ]); + + $this->commonLdapMocks(1, 1, 1, 2, 1); + $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn'); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'cn' => [$this->mockUser->name], + 'dn' => $ldapDn, + 'my_custom_id' => ['cooluser456'], + 'mail' => [$this->mockUser->email], + ]]); + + $resp = $this->mockUserLogin(); + $resp->assertRedirect('/'); + + $this->assertDatabaseHas('users', ['email' => $this->mockUser->email]); + $this->assertEquals($this->mockUser->email, user()->email); + $this->assertEquals('cooluser456', user()->external_auth_id); + } + public function test_user_filter_default_placeholder_format() { config()->set('services.ldap.user_filter', '(&(uid={user}))'); diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index 8508568f1f4..0fcfdc5ff87 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -471,7 +471,28 @@ public function test_auth_uses_configured_external_id_claim_option() $this->assertEquals('xXBennyTheGeezXx', $user->external_auth_id); } - public function test_auth_uses_mulitple_display_name_claims_if_configured() + public function test_auth_uses_external_id_as_exact_value() + { + // External auth id the same as we expect below but different casing + User::query()->forceCreate([ + 'email' => 'otheruser@example.com', + 'external_auth_id' => 'Benni202', + 'email_confirmed' => true, + 'name' => 'Barry Scott', + ]); + + $resp = $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benni202', + ]); + $resp->assertRedirect('/'); + + $this->assertDatabaseHas('users', ['email' => 'benny@example.com']); + $this->assertEquals('benny@example.com', user()->email); + $this->assertEquals('benni202', user()->external_auth_id); + } + + public function test_auth_uses_multiple_display_name_claims_if_configured() { config()->set(['oidc.display_name_claims' => 'first_name|last_name']); diff --git a/tests/Auth/Saml2Test.php b/tests/Auth/Saml2Test.php index 6a3063bcf51..fbaffceb38e 100644 --- a/tests/Auth/Saml2Test.php +++ b/tests/Auth/Saml2Test.php @@ -419,6 +419,29 @@ public function test_login_where_existing_non_saml_user_shows_warning() $acsPost->assertSee('A user with the email user@example.com already exists but with different credentials'); } + public function test_login_uses_exact_match_for_external_auth_values() + { + $this->post('/saml2/login'); + config()->set(['saml2.onelogin.strict' => false]); + + // Make the user pre-existing in DB with auth_id of different casing + User::query()->forceCreate([ + 'email' => 'otheruser@example.com', + 'external_auth_id' => 'UsEr', + 'email_confirmed' => true, + 'name' => 'Barry Scott', + ]); + + $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]); + + $this->assertTrue($this->isAuthenticated()); + $this->assertDatabaseHas('users', [ + 'email' => 'user@example.com', + ]); + $this->assertEquals('user@example.com', user()->email); + $this->assertEquals('user', user()->external_auth_id); + } + public function test_login_request_contains_expected_default_authncontext() { $authReq = $this->getAuthnRequest(); From 2183fc7fc86c0aa183c5eb2068fc691aa97f576d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 28 Jul 2026 13:27:42 +0100 Subject: [PATCH 288/398] API: Limited exception details shown Updated exception handler to reduce the amount of detail shown to prevent potentially sensitive details (like internal paths) being shown in the error message. Added an interface for specifically marking exceptions whos messages we may want to show. Thanks to Tanner Marks for reporting. --- app/Exceptions/ApiAuthException.php | 7 ++- app/Exceptions/Handler.php | 19 ++++++-- app/Exceptions/NotifyException.php | 11 +++-- app/Exceptions/PermissionsException.php | 6 ++- app/Exceptions/PrettyException.php | 14 +++++- app/Exceptions/ShowsApiExceptionMessage.php | 13 +++++ tests/Api/ApiErrorTest.php | 53 +++++++++++++++++++++ tests/Api/ImageGalleryApiTest.php | 16 +++++++ 8 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 app/Exceptions/ShowsApiExceptionMessage.php create mode 100644 tests/Api/ApiErrorTest.php diff --git a/app/Exceptions/ApiAuthException.php b/app/Exceptions/ApiAuthException.php index 070f7a8df0b..55a6afae6a4 100644 --- a/app/Exceptions/ApiAuthException.php +++ b/app/Exceptions/ApiAuthException.php @@ -4,7 +4,7 @@ use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; -class ApiAuthException extends \Exception implements HttpExceptionInterface +class ApiAuthException extends \Exception implements HttpExceptionInterface, ShowsApiExceptionMessage { protected int $status; @@ -23,4 +23,9 @@ public function getHeaders(): array { return []; } + + public function getMessageForApi(): string + { + return $this->getMessage(); + } } diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 97a2b0a4f24..a7f23e5f41a 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -126,16 +126,25 @@ protected function renderApiException(Throwable $e): JsonResponse $headers = $e->getHeaders(); } - if ($e instanceof ModelNotFoundException) { - $code = 404; - } - $responseData = [ 'error' => [ - 'message' => $e->getMessage(), + 'message' => 'An error occurred', ], ]; + if ($e instanceof ModelNotFoundException) { + $responseData['error']['message'] = 'The requested resource could not be found.'; + $code = 404; + } + + if ($e instanceof ShowsApiExceptionMessage) { + $responseData['error']['message'] = $e->getMessageForApi(); + } + + if (app()->hasDebugModeEnabled()) { + $responseData['error']['message'] = $e->getMessage(); + } + if ($e instanceof ValidationException) { $responseData['error']['message'] = 'The given data was invalid.'; $responseData['error']['validation'] = $e->errors(); diff --git a/app/Exceptions/NotifyException.php b/app/Exceptions/NotifyException.php index d2fba30c4e9..b0514059d79 100644 --- a/app/Exceptions/NotifyException.php +++ b/app/Exceptions/NotifyException.php @@ -8,13 +8,13 @@ /** * An exception that is thrown to notify the user of something which went wrong. - * Typically these should be translated messages since they will be shown to the end user - * via a pop up notification error message in the UI. + * Typically, these should be translated messages since they will be shown to the end user + * via a pop-up notification error message in the UI. * * This exception is not intended to be used for internal system/application errors, * and therefore will not be logged by the exception handler. */ -class NotifyException extends Exception implements Responsable, HttpExceptionInterface +class NotifyException extends Exception implements Responsable, HttpExceptionInterface, ShowsApiExceptionMessage { public function __construct( string $message, @@ -61,4 +61,9 @@ public function toResponse($request) return redirect($this->redirectLocation); } + + public function getMessageForApi(): string + { + return $this->getMessage(); + } } diff --git a/app/Exceptions/PermissionsException.php b/app/Exceptions/PermissionsException.php index 64da55d21f4..ca5104df9a3 100644 --- a/app/Exceptions/PermissionsException.php +++ b/app/Exceptions/PermissionsException.php @@ -4,6 +4,10 @@ use Exception; -class PermissionsException extends Exception +class PermissionsException extends Exception implements ShowsApiExceptionMessage { + public function getMessageForApi(): string + { + return $this->getMessage(); + } } diff --git a/app/Exceptions/PrettyException.php b/app/Exceptions/PrettyException.php index 606085231f7..d8a9d315021 100644 --- a/app/Exceptions/PrettyException.php +++ b/app/Exceptions/PrettyException.php @@ -6,7 +6,7 @@ use Illuminate\Contracts\Support\Responsable; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; -class PrettyException extends Exception implements Responsable, HttpExceptionInterface +class PrettyException extends Exception implements Responsable, HttpExceptionInterface, ShowsApiExceptionMessage { protected ?string $subtitle = null; protected ?string $details = null; @@ -56,4 +56,16 @@ public function getHeaders(): array { return []; } + + public function getMessageForApi(): string + { + $message = $this->getMessage() . '.'; + if ($this->subtitle) { + $message .= " {$this->subtitle}."; + } + if ($this->details) { + $message .= " {$this->details}."; + } + return $message; + } } diff --git a/app/Exceptions/ShowsApiExceptionMessage.php b/app/Exceptions/ShowsApiExceptionMessage.php new file mode 100644 index 00000000000..66bf4388642 --- /dev/null +++ b/app/Exceptions/ShowsApiExceptionMessage.php @@ -0,0 +1,13 @@ +partialMock(SystemApiController::class); + $mockController->shouldReceive('read')->andThrow(\InvalidArgumentException::class, 'Potentially sensitive data', 500); + + $resp = $this->actingAsApiEditor()->get('/api/system'); + $resp->assertStatus(500); + $resp->assertDontSee('Potentially sensitive data', false); + $resp->assertJsonPath('error.message', 'An error occurred'); + + config(['app.debug' => true]); + + $resp = $this->actingAsApiEditor()->get('/api/system'); + $resp->assertStatus(500); + $resp->assertJsonPath('error.message', 'Potentially sensitive data'); + } + + public function test_exception_message_when_model_not_found() + { + $resp = $this->actingAsApiEditor()->get('/api/books/123456789'); + $resp->assertStatus(404); + $resp->assertSee('The requested resource could not be found.', false); + } + + public function test_pretty_exception_messages_are_provided_in_non_debug_mode() + { + $mockController = $this->partialMock(SystemApiController::class); + $exception = new PrettyException('Mr Error is here!'); + $exception->setSubtitle('Oh no!'); + $exception->setDetails('Something has really gone wrong'); + $mockController->shouldReceive('read')->andThrow($exception); + + $resp = $this->actingAsApiEditor()->get('/api/system'); + $resp->assertStatus(500); + $resp->assertJson([ + 'error' => [ + 'message' => 'Mr Error is here!. Oh no!. Something has really gone wrong.' + ] + ]); + } +} diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php index 09dba84f548..3db421ce881 100644 --- a/tests/Api/ImageGalleryApiTest.php +++ b/tests/Api/ImageGalleryApiTest.php @@ -454,4 +454,20 @@ public function test_delete_endpoint_requires_image_delete_permission() $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); $resp->assertStatus(204); } + + public function test_delete_works_on_orphaned_image() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + + $image = Image::query()->findOrFail($data['response']->id); + + $this->entities->destroy($imagePage); + + $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); + + $resp->assertStatus(204); + $this->assertDatabaseMissing('images', ['id' => $image->id]); + } } From 5ed685d1e6a9bb1f90344ab771a013ea90ac492e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 28 Jul 2026 13:35:42 +0100 Subject: [PATCH 289/398] Image API: Update delete permission check Aligns with ImageController permissions by checking related item view access only if that still exists. Thanks to Tanner Marks for reporting. --- app/Uploads/Controllers/ImageGalleryApiController.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/Uploads/Controllers/ImageGalleryApiController.php b/app/Uploads/Controllers/ImageGalleryApiController.php index 6a72e4c30e4..5c18f8a680a 100644 --- a/app/Uploads/Controllers/ImageGalleryApiController.php +++ b/app/Uploads/Controllers/ImageGalleryApiController.php @@ -161,8 +161,13 @@ public function update(Request $request, string $id) public function delete(string $id) { $image = $this->imageRepo->getById($id); - $this->checkOwnablePermission(Permission::PageView, $image->getPage()); $this->checkOwnablePermission(Permission::ImageDelete, $image); + + $relatedPage = $image->getPage(); + if ($relatedPage) { + $this->checkOwnablePermission(Permission::PageView, $image->getPage()); + } + $this->imageRepo->destroyImage($image); return response('', 204); From badc4f9b8e6a13e97e141119089358e7a0901bd2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 28 Jul 2026 14:29:36 +0100 Subject: [PATCH 290/398] Gallery API: Aligned image querying Updated endpoint image querying to generally follow the same logic to ensure filtering on just the expected types which are intended to be managed by this endpoint, and it err on the side of caution in terms of access control. --- .../Controllers/ImageGalleryApiController.php | 16 +++------- app/Uploads/Image.php | 1 + app/Uploads/ImageRepo.php | 7 +++++ tests/Api/ImageGalleryApiTest.php | 31 +++++++++++++++++-- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/app/Uploads/Controllers/ImageGalleryApiController.php b/app/Uploads/Controllers/ImageGalleryApiController.php index 5c18f8a680a..abaf6939ff5 100644 --- a/app/Uploads/Controllers/ImageGalleryApiController.php +++ b/app/Uploads/Controllers/ImageGalleryApiController.php @@ -97,7 +97,7 @@ public function create(Request $request) */ public function read(string $id) { - $image = Image::query()->scopes(['visible'])->findOrFail($id); + $image = $this->imageRepo->getVisiblePageImageById($id); return response()->json($this->formatForSingleResponse($image)); } @@ -108,7 +108,7 @@ public function read(string $id) */ public function readData(string $id) { - $image = Image::query()->scopes(['visible'])->findOrFail($id); + $image = $this->imageRepo->getVisiblePageImageById($id); return $this->imageService->streamImageFromStorageResponse('gallery', $image->path); } @@ -141,8 +141,7 @@ public function readDataForUrl(Request $request) public function update(Request $request, string $id) { $data = $this->validate($request, $this->rules()['update']); - $image = $this->imageRepo->getById($id); - $this->checkOwnablePermission(Permission::PageView, $image->getPage()); + $image = $this->imageRepo->getVisiblePageImageById($id); $this->checkOwnablePermission(Permission::ImageUpdate, $image); $this->imageRepo->updateImageDetails($image, $data); @@ -160,21 +159,16 @@ public function update(Request $request, string $id) */ public function delete(string $id) { - $image = $this->imageRepo->getById($id); + $image = $this->imageRepo->getVisiblePageImageById($id); $this->checkOwnablePermission(Permission::ImageDelete, $image); - $relatedPage = $image->getPage(); - if ($relatedPage) { - $this->checkOwnablePermission(Permission::PageView, $image->getPage()); - } - $this->imageRepo->destroyImage($image); return response('', 204); } /** - * Format the given image model for single-result display. + * Format the given image model for a single-result display. */ protected function formatForSingleResponse(Image $image): array { diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index 81b6db6fd22..879e0043a1e 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -39,6 +39,7 @@ public function jointPermissions(): HasMany /** * Scope the query to just the images visible to the user based upon the * user visibility of the uploaded_to page. + * This limits results to just page-based images (gallery and drawio types). */ public function scopeVisible(Builder $query): Builder { diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index e87e22b3a3c..ef3ed9e1f89 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -27,6 +27,13 @@ public function getById($id): Image return Image::query()->findOrFail($id); } + public function getVisiblePageImageById($id): Image + { + return Image::query() + ->scopes('visible') + ->findOrFail($id); + } + /** * Execute a paginated query, returning in a standard format. * Also runs the query through the restriction system. diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php index 3db421ce881..8944dcffd33 100644 --- a/tests/Api/ImageGalleryApiTest.php +++ b/tests/Api/ImageGalleryApiTest.php @@ -423,6 +423,32 @@ public function test_update_endpoint_requires_image_update_permission() $resp->assertStatus(200); } + public function test_update_endpoint_only_works_on_gallery_and_drawio_images() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $statusByImageType = [ + 'gallery' => 200, + 'drawio' => 200, + 'cover_book' => 404, + 'user' => 404, + 'system' => 404, + ]; + + foreach ($statusByImageType as $type => $status) { + $image->type = $type; + $image->save(); + + $resp = $this->putJson($this->baseEndpoint . "/{$image->id}", [ + 'name' => "My updated {$type} image!", + ]); + $resp->assertStatus($status); + } + } + public function test_delete_endpoint() { $this->actingAsApiAdmin(); @@ -455,7 +481,7 @@ public function test_delete_endpoint_requires_image_delete_permission() $resp->assertStatus(204); } - public function test_delete_works_on_orphaned_image() + public function test_delete_limited_to_visible_images() { $this->actingAsApiAdmin(); $imagePage = $this->entities->page(); @@ -467,7 +493,6 @@ public function test_delete_works_on_orphaned_image() $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); - $resp->assertStatus(204); - $this->assertDatabaseMissing('images', ['id' => $image->id]); + $resp->assertStatus(404); } } From a0108ae0ea9880da8baf9c41083b4ba3db9c9523 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 28 Jul 2026 15:19:20 +0100 Subject: [PATCH 291/398] Maintenance: Fixed test issues from recent changes --- app/Access/Guards/ExternalBaseSessionGuard.php | 4 ++-- app/Access/LoginService.php | 5 ++++- app/Util/HtmlPurifier/SrcsetAttrDef.php | 6 +++--- tests/Exports/HtmlExportTest.php | 3 +-- tests/Exports/PdfExportTest.php | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/Access/Guards/ExternalBaseSessionGuard.php b/app/Access/Guards/ExternalBaseSessionGuard.php index 91239599ba9..b389031824b 100644 --- a/app/Access/Guards/ExternalBaseSessionGuard.php +++ b/app/Access/Guards/ExternalBaseSessionGuard.php @@ -30,7 +30,7 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * The user we last attempted to retrieve. */ - protected Authenticatable|null $lastAttempted; + protected Authenticatable|null $lastAttempted = null; /** * The session used by the guard. @@ -203,7 +203,7 @@ protected function clearUserDataFromStorage(): void /** * Get the last user we attempted to authenticate. */ - public function getLastAttempted(): Authenticatable + public function getLastAttempted(): Authenticatable|null { return $this->lastAttempted; } diff --git a/app/Access/LoginService.php b/app/Access/LoginService.php index 6547926c559..46545f798b7 100644 --- a/app/Access/LoginService.php +++ b/app/Access/LoginService.php @@ -13,6 +13,7 @@ use BookStack\Theming\ThemeEvents; use BookStack\Users\Models\User; use Exception; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Facades\Hash; class LoginService @@ -178,7 +179,9 @@ public function attempt(array $credentials, string $method, bool $remember = fal // Perform a dummy hash check to balance out the time of a login with an existing known user // with that of a user not in the system (which we don't perform a hash check for in the above). - if (!$result && auth()->getLastAttempted() === null) { + /** @var Authenticatable|null $lastAttempted */ + $lastAttempted = auth()->getLastAttempted(); + if (!$result && $lastAttempted === null) { Hash::check($credentials['password'], '$2y$04$A.H9icXH4/lxLd9DHuaYqO/GVBd0OKetxyY0txmNfTAlPLVnTBx3y'); } diff --git a/app/Util/HtmlPurifier/SrcsetAttrDef.php b/app/Util/HtmlPurifier/SrcsetAttrDef.php index 2f0ffffab30..783f1f86cc7 100644 --- a/app/Util/HtmlPurifier/SrcsetAttrDef.php +++ b/app/Util/HtmlPurifier/SrcsetAttrDef.php @@ -87,7 +87,7 @@ private function parseImageSources($string) } if ($state === 'in_descriptor') { - if ($c !== null && strpos($asciiWhitespace, $c) !== false) { + if ($c !== null && str_contains($asciiWhitespace, $c)) { if ($currentDescriptor !== '') { $descriptors[] = $currentDescriptor; } @@ -119,8 +119,8 @@ private function parseImageSources($string) } else { $currentDescriptor .= $c; } - } else if ($state === 'after_descriptor') { - if ($c !== null && strpos($asciiWhitespace, $c) !== false) { + } else { + if ($c !== null && str_contains($asciiWhitespace, $c)) { // Stay in this state } else if ($c === null) { break; diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php index 223a8c92285..3eb04a7ee20 100644 --- a/tests/Exports/HtmlExportTest.php +++ b/tests/Exports/HtmlExportTest.php @@ -166,8 +166,7 @@ public function test_page_export_contained_html_image_fetches_only_run_when_url_ $storageDisk->delete('uploads/svg_test.svg'); $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false); - $resp->assertSee('http://localhost/uploads/svg_test.svg'); - $resp->assertSee('src="/uploads/svg_test.svg"', false); + $resp->assertSee('src="http://localhost/uploads/svg_test.svg"', false); } public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local() diff --git a/tests/Exports/PdfExportTest.php b/tests/Exports/PdfExportTest.php index 78da3b0c2cc..739406e8daa 100644 --- a/tests/Exports/PdfExportTest.php +++ b/tests/Exports/PdfExportTest.php @@ -58,7 +58,7 @@ public function test_page_pdf_export_converts_iframes_to_links() $this->asEditor()->get($page->getUrl('/export/pdf')); $this->assertStringNotContainsString('iframe>', $pdfHtml); - $this->assertStringContainsString('

    https://www.youtube.com/embed/ShqUjt33uOs

    ', $pdfHtml); + $this->assertStringContainsString('

    http://www.youtube.com/embed/ShqUjt33uOs

    ', $pdfHtml); } public function test_page_pdf_export_opens_details_blocks() From 17398590d3f5cb0ae730ef078c683c7a273f98d9 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 29 Jul 2026 04:31:29 +0000 Subject: [PATCH 292/398] New Crowdin translations by GitHub Action --- lang/fr/activities.php | 4 +- lang/fr/auth.php | 2 +- lang/fr/entities.php | 6 +-- lang/fr/preferences.php | 2 +- lang/fr/settings.php | 20 +++++----- lang/it/entities.php | 8 ++-- lang/nl/activities.php | 4 +- lang/nl/auth.php | 2 +- lang/nl/entities.php | 6 +-- lang/nl/errors.php | 2 +- lang/nl/settings.php | 10 ++--- lang/sv/activities.php | 4 +- lang/sv/auth.php | 2 +- lang/sv/entities.php | 60 ++++++++++++++-------------- lang/sv/errors.php | 20 +++++----- lang/sv/notifications.php | 4 +- lang/sv/preferences.php | 62 ++++++++++++++--------------- lang/sv/settings.php | 84 +++++++++++++++++++-------------------- lang/sv/validation.php | 10 ++--- lang/tr/auth.php | 4 +- lang/tr/common.php | 2 +- lang/tr/editor.php | 2 +- 22 files changed, 160 insertions(+), 160 deletions(-) diff --git a/lang/fr/activities.php b/lang/fr/activities.php index fcacc90898e..e0c51962ae8 100644 --- a/lang/fr/activities.php +++ b/lang/fr/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Utilisateur mis à jour avec succès', 'user_delete' => 'utilisateur supprimé', 'user_delete_notification' => 'Utilisateur supprimé avec succès', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'réinitialiser l\'authentification multifacteur pour l\'utilisateur', + 'user_mfa_reset_notification' => 'Les méthodes d\'authentification multifacteurs sont réinitialisées', // API Tokens 'api_token_create' => 'a créé un jeton API', diff --git a/lang/fr/auth.php b/lang/fr/auth.php index a7fe59d0ccb..e2f94904877 100644 --- a/lang/fr/auth.php +++ b/lang/fr/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Ces informations ne correspondent à aucun compte.', 'throttle' => 'Trop d\'essais, veuillez réessayer dans :seconds secondes.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Trop de tentatives de vérification multifactorielle. Veuillez réessayer dans :secondes secondes.', // Login & Register 'sign_up' => 'S\'inscrire', diff --git a/lang/fr/entities.php b/lang/fr/entities.php index fa0808912a7..3479ab57221 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Afficher/masquer la barre latérale', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Contenu de la page', + 'page_contents_none' => 'Aucun titre n\'a été trouvé dans le contenu de la page.', + 'page_contents_info' => 'Le menu de contenu est généré à partir de tous les formats de titres utilisés dans la page.', 'page_tags' => 'Étiquettes de la page', 'chapter_tags' => 'Étiquettes du chapitre', 'book_tags' => 'Étiquettes du livre', diff --git a/lang/fr/preferences.php b/lang/fr/preferences.php index dbae975f31d..b16d5acf2df 100644 --- a/lang/fr/preferences.php +++ b/lang/fr/preferences.php @@ -15,7 +15,7 @@ 'shortcuts_section_navigation' => 'Navigation', 'shortcuts_section_actions' => 'Actions communes', 'shortcuts_save' => 'Sauvegarder les raccourcis', - 'shortcuts_overlay_desc' => 'Note : Lorsque les raccourcis sont activés, assistant est disponible en appuyant sur "?" qui mettra en surbrillance les raccourcis disponibles pour les actions actuellement visibles à l\'écran.', + 'shortcuts_overlay_desc' => 'Note : Lorsque les raccourcis sont activés, assistant est disponible en appuyant sur «?» qui mettra en surbrillance les raccourcis disponibles pour les actions actuellement visibles à l\'écran.', 'shortcuts_update_success' => 'Les préférences de raccourci ont été mises à jour !', 'shortcuts_overview_desc' => 'Gérer les raccourcis clavier que vous pouvez utiliser pour naviguer dans l\'interface utilisateur du système.', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index 697e5ffa06d..cd2368db18c 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -39,7 +39,7 @@ 'app_homepage_desc' => 'Choisissez une page à afficher sur la page d\'accueil au lieu de la vue par défaut. Les permissions sont ignorées pour les pages sélectionnées.', 'app_homepage_select' => 'Choisissez une page', 'app_footer_links' => 'Liens de pied de page', - 'app_footer_links_desc' => 'Ajoutez des liens dans le pied de page du site. Ils seront affichés en bas de la plupart des pages, incluant celles qui ne nécesittent pas de connexion. Vous pouvez utiliser l\'étiquette "trans::" pour utiliser les traductions définies par le système. Par exemple, utiliser "trans::common.privacy_policy" fournira la traduction de "Politique de Confidentalité" et "trans::common.terms_of_service" fournira la traduction de "Conditions d\'utilisation".', + 'app_footer_links_desc' => 'Ajoutez des liens dans le pied de page du site. Ils seront affichés en bas de la plupart des pages, incluant celles qui ne nécessitent pas de connexion. Vous pouvez utiliser l\'étiquette "trans::" pour utiliser les traductions définies par le système. Par exemple, utiliser "trans::common.privacy_policy" fournira la traduction de "Politique de Confidentalité" et "trans::common.terms_of_service" fournira la traduction de "Conditions d\'utilisation".', 'app_footer_links_label' => 'Libellé du lien', 'app_footer_links_url' => 'URL du lien', 'app_footer_links_add' => 'Ajouter un lien en pied de page', @@ -61,17 +61,17 @@ 'page_draft_color' => 'Couleur des brouillons', // Registration Settings - 'reg_settings' => 'Préférence pour l\'inscription', + 'reg_settings' => 'Paramètres d\'inscription', 'reg_enable' => 'Activer l\'inscription', 'reg_enable_toggle' => 'Activer l\'inscription', - 'reg_enable_desc' => 'Lorsque l\'inscription est activée, l\'utilisateur pourra s\'enregistrer en tant qu\'utilisateur de l\'application. Lors de l\'inscription, ils se voient attribuer un rôle par défaut.', - 'reg_default_role' => 'Rôle par défaut lors de l\'inscription', + 'reg_enable_desc' => 'Lorsque l\'inscription est activée, l\'utilisateur peut s\'inscrire lui-même en tant qu\'utilisateur de l\'application. Lors de son inscription, il se voit attribuer un rôle unique par défaut.', + 'reg_default_role' => 'Rôle de l\'utilisateur par défaut après l\'inscription', 'reg_enable_external_warning' => 'L\'option ci-dessus est ignorée lorsque l\'authentification externe LDAP ou SAML est activée. Les comptes utilisateur pour les membres non existants seront créés automatiquement si l\'authentification, par rapport au système externe utilisé, est réussie.', 'reg_email_confirmation' => 'Confirmation de l\'e-mail', 'reg_email_confirmation_toggle' => 'Obliger la confirmation par e-mail ?', 'reg_confirm_email_desc' => 'Si la restriction de domaine est activée, la confirmation sera automatiquement obligatoire et cette valeur sera ignorée.', - 'reg_confirm_restrict_domain' => 'Restreindre l\'inscription à un domaine', - 'reg_confirm_restrict_domain_desc' => 'Entrez une liste de domaines acceptés lors de l\'inscription, séparés par une virgule. Les utilisateurs recevront un e-mail de confirmation à cette adresse.
    Les utilisateurs pourront changer leur adresse après inscription s\'ils le souhaitent.', + 'reg_confirm_restrict_domain' => 'Restriction de domaine', + 'reg_confirm_restrict_domain_desc' => 'Indiquez, séparés par des virgules, les domaines de messagerie autorisés pour l\'inscription. Les utilisateurs recevront un e-mail pour confirmer leur adresse avant de pouvoir utiliser l\'application.
    Notez qu\'ils pourront modifier leur adresse e-mail après leur inscription.', 'reg_confirm_restrict_domain_placeholder' => 'Aucune restriction en place', // Sorting Settings @@ -207,7 +207,7 @@ '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_controlled_by_page_delete' => 'Contrôlé par les autorisations de suppression de page', 'role_save' => 'Enregistrer le rôle', 'role_users' => 'Utilisateurs ayant ce rôle', 'role_users_none' => 'Aucun utilisateur avec ce rôle actuellement', @@ -264,9 +264,9 @@ 'users_mfa_desc' => 'Configurer l\'authentification multi-facteurs ajoute une couche supplémentaire de sécurité à votre compte utilisateur.', 'users_mfa_x_methods' => ':count méthode configurée|:count méthodes configurées', 'users_mfa_configure' => 'Méthode de configuration', - '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?', + 'users_mfa_reset' => 'Réinitialiser les méthodes d\'authentification multifacteurs', + 'users_mfa_reset_desc' => 'Cette action réinitialisera et supprimera toutes les méthodes d\'authentification multifacteurs configurées pour cet utilisateur. Si l\'authentification multifacteurs est requise par l\'un de ses rôles, il sera invité à configurer de nouvelles méthodes lors de sa prochaine connexion.', + 'users_mfa_reset_confirm' => 'Êtes-vous sûr de vouloir réinitialiser l\'authentification multifacteurs pour cet utilisateur ?', // API Tokens 'user_api_token_create' => 'Créer un nouveau jeton API', diff --git a/lang/it/entities.php b/lang/it/entities.php index 89e1cd98831..fefb9d34b08 100644 --- a/lang/it/entities.php +++ b/lang/it/entities.php @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => 'Le regole delle opzioni di ordinamento automatico possono essere create nell\'area delle impostazioni "Elenchi e ordinamento" da un utente con le relative autorizzazioni.', 'books_sort_named' => 'Ordina il libro :bookName', 'books_sort_name' => 'Ordina per Nome', 'books_sort_created' => 'Ordina per Data di creazione', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Attiva/disattiva barra laterale', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Contenuto della pagina', + 'page_contents_none' => 'Nessun titolo trovato nel contenuto della pagina.', + 'page_contents_info' => 'Il sommario viene generato sulla base dei formati di intestazione utilizzati nella pagina.', 'page_tags' => 'Tag pagina', 'chapter_tags' => 'Tag capitolo', 'book_tags' => 'Tag libro', diff --git a/lang/nl/activities.php b/lang/nl/activities.php index 55356966ddc..23519f9c9d7 100644 --- a/lang/nl/activities.php +++ b/lang/nl/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Gebruiker succesvol bijgewerkt', 'user_delete' => 'verwijderde gebruiker', 'user_delete_notification' => 'Gebruiker succesvol verwijderd', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'herstel meervoudige verificatie voor gebruiker', + 'user_mfa_reset_notification' => 'Meervoudige verificatie methodes hersteld', // API Tokens 'api_token_create' => 'API-token aangemaakt', diff --git a/lang/nl/auth.php b/lang/nl/auth.php index 49d04dd4fbf..3f67f8a6233 100644 --- a/lang/nl/auth.php +++ b/lang/nl/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Deze inloggegevens zijn niet bij ons bekend.', 'throttle' => 'Te veel inlogpogingen! Probeer het opnieuw na :seconds seconden.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Te veel pogingen om te verifiëren met meervoudige verificatie. Probeer het opnieuw na :seconds seconden.', // Login & Register 'sign_up' => 'Registreer', diff --git a/lang/nl/entities.php b/lang/nl/entities.php index 3695277a71e..6f5f7a37b7a 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => 'Regels voor automatisch sorteren kunnen worden aangemaakt door een bevoegde gebruiker in het gedeelte "Lijsten & Sorteren" van de instellingen.', 'books_sort_named' => 'Sorteer boek :bookName', 'books_sort_name' => 'Sorteren op naam', 'books_sort_created' => 'Sorteren op datum van aanmaken', @@ -332,8 +332,8 @@ // Editor Sidebar 'toggle_sidebar' => 'Zijbalk Tonen/Verbergen', 'page_contents' => 'Pagina Inhoud', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents_none' => 'Geen koppen gevonden binnen de inhoud van deze pagina.', + 'page_contents_info' => 'Het inhoudsmenu wordt gemaakt van alle koppen op een pagina.', 'page_tags' => 'Pagina Labels', 'chapter_tags' => 'Hoofdstuk Labels', 'book_tags' => 'Boek Labels', diff --git a/lang/nl/errors.php b/lang/nl/errors.php index 0e478fd5ac6..8dc197efe3e 100644 --- a/lang/nl/errors.php +++ b/lang/nl/errors.php @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'Het opgegeven geheim voor de API-token is onjuist', 'api_user_no_api_permission' => 'De eigenaar van de gebruikte API-token heeft geen machtiging om API calls te maken', 'api_user_token_expired' => 'De gebruikte autorisatie token is verlopen', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Alleen GET verzoeken zijn toegestaan wanneer de API wordt gebruikt met cookie-gebaseerde authenticatie', // Settings & Maintenance 'maintenance_test_email_failure' => 'Fout opgetreden bij het verzenden van een test email:', diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 977aa55fcaa..0d0ddfa674a 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -104,7 +104,7 @@ 'sort_rule_op_chapters_first' => 'Hoofdstukken Eerst', 'sort_rule_op_chapters_last' => 'Hoofdstukken Laatst', 'sorting_page_limits' => 'Weergavelimiet Per Pagina', - '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' => 'Stel in hoeveel items er op een pagina worden laten zien in de verschillende lijstweergaves. Een lager aantal verbeterd de snelheid, een hoger aantal verminderd het doorklikken door pagina\'s. Het wordt aanbevolen om een meervoud van 6 te gebruiken.', // Maintenance settings 'maint' => 'Onderhoud', @@ -207,7 +207,7 @@ '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_controlled_by_page_delete' => 'Ingesteld volgens pagina verwijder machtigingen', 'role_save' => 'Rol Opslaan', 'role_users' => 'Gebruikers in deze rol', 'role_users_none' => 'Geen enkele gebruiker heeft deze rol', @@ -264,9 +264,9 @@ 'users_mfa_desc' => 'Stel meervoudige verificatie in als extra beveiligingslaag voor je gebruikersaccount.', 'users_mfa_x_methods' => ':count methode geconfigureerd|:count methoden geconfigureerd', 'users_mfa_configure' => 'Configureer methoden', - '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?', + 'users_mfa_reset' => 'Herstel Meervoudige Verificatie Methodes', + 'users_mfa_reset_desc' => 'Dit zal alle methodes voor meervoudige verificatie van deze gebruiker wissen. Als meervoudige verificatie vereist is vanwege een van hun rollen, worden ze bij hun volgende inlogpoging gevraagd om nieuwe methodes te configureren.', + 'users_mfa_reset_confirm' => 'Weet je zeker dat je de meervoudige verificatie van deze gebruiker wilt herstellen?', // API Tokens 'user_api_token_create' => 'API-token aanmaken', diff --git a/lang/sv/activities.php b/lang/sv/activities.php index c501c675273..c7afc0bace2 100644 --- a/lang/sv/activities.php +++ b/lang/sv/activities.php @@ -99,8 +99,8 @@ 'user_update_notification' => 'Användaren har uppdaterats', 'user_delete' => 'raderad användare', 'user_delete_notification' => 'Användaren har tagits bort', - 'user_mfa_reset' => 'reset MFA for user', - 'user_mfa_reset_notification' => 'Multi-factor authentication methods reset', + 'user_mfa_reset' => 'återställ MFA för användare', + 'user_mfa_reset_notification' => 'Metoder för multifaktorautentisering återställda', // API Tokens 'api_token_create' => 'skapade API-token', diff --git a/lang/sv/auth.php b/lang/sv/auth.php index 6c94f21f9e0..44275e787d8 100644 --- a/lang/sv/auth.php +++ b/lang/sv/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Uppgifterna stämmer inte överens med våra register.', 'throttle' => 'För många inloggningsförsök. Prova igen om :seconds sekunder.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'För många försök till multifaktorverifiering. Försök igen om :seconds sekunder.', // Login & Register 'sign_up' => 'Skapa konto', diff --git a/lang/sv/entities.php b/lang/sv/entities.php index 1df81684913..6a0e2f0995b 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -63,10 +63,10 @@ 'import_delete_desc' => 'Detta kommer att ta bort den uppladdade ZIP-baserade importfilen och kan inte ångras.', 'import_errors' => 'Importfel', 'import_errors_desc' => 'Följande fel inträffade under importförsöket:', - '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' => 'Navigera mellan syskon för sida', + 'breadcrumb_siblings_for_chapter' => 'Navigera mellan syskon för kapitel', + 'breadcrumb_siblings_for_book' => 'Navigera mellan syskon för bok', + 'breadcrumb_siblings_for_bookshelf' => 'Navigera mellan syskon för hylla', // Permissions and restrictions 'permissions' => 'Rättigheter', @@ -173,7 +173,7 @@ '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_auto_sort_creation_hint' => 'Regler för automatisk sortering kan skapas i inställningsområdet "Listor och sortering" av en användare med relevanta behörigheter.', 'books_sort_named' => 'Sortera boken :bookName', 'books_sort_name' => 'Sortera utifrån namn', 'books_sort_created' => 'Sortera utifrån skapelse', @@ -253,7 +253,7 @@ 'pages_edit_switch_to_markdown_stable' => '(Stabilt innehåll)', 'pages_edit_switch_to_wysiwyg' => 'Växla till WYSIWYG-redigerare', 'pages_edit_switch_to_new_wysiwyg' => 'Växla till ny WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(I betatestning)', 'pages_edit_set_changelog' => 'Beskriv dina ändringar', 'pages_edit_enter_changelog_desc' => 'Ange en kort beskrivning av de ändringar du har gjort', 'pages_edit_enter_changelog' => 'Ändringslogg', @@ -272,8 +272,8 @@ 'pages_md_insert_link' => 'Infoga länk', 'pages_md_insert_drawing' => 'Infoga teckning', 'pages_md_show_preview' => 'Visa förhandsgranskning', - 'pages_md_sync_scroll' => 'Sync preview scroll', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_sync_scroll' => 'Synkronisera förhandsgranskningsrullning', + 'pages_md_plain_editor' => 'Textredigerare (plaintext)', 'pages_drawing_unsaved' => 'Osparad ritning hittades', 'pages_drawing_unsaved_confirm' => 'Osparade ritningsdata hittades från ett tidigare misslyckat sparförsök. Vill du återställa och fortsätta redigera den osparade ritningen?', 'pages_not_in_chapter' => 'Sidan ligger inte i något kapitel', @@ -306,10 +306,10 @@ 'pages_edit_content_link' => 'Hoppa till sektionen i redigeraren', 'pages_pointer_enter_mode' => 'Ange markeringsläge för sektion', 'pages_pointer_label' => 'Alternativ för sidsektion', - 'pages_pointer_permalink' => 'Page Section Permalink', - 'pages_pointer_include_tag' => 'Page Section Include Tag', - 'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag', - 'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink', + 'pages_pointer_permalink' => 'Permalänk för sidavsnitt', + 'pages_pointer_include_tag' => 'Include-tagg för sidavsnitt', + 'pages_pointer_toggle_link' => 'Permalänksläge, tryck för att visa include-tagg', + 'pages_pointer_toggle_include' => 'Include-taggläge, tryck för att visa permalänk', 'pages_permissions_active' => 'Anpassade rättigheter är i bruk', 'pages_initial_revision' => 'Första publicering', 'pages_references_update_revision' => 'Automatisk uppdatering av interna länkar', @@ -331,9 +331,9 @@ // Editor Sidebar 'toggle_sidebar' => 'Visa/Dölj sidopanel', - 'page_contents' => 'Page Contents', - 'page_contents_none' => 'No headings were found in the page content.', - 'page_contents_info' => 'The contents menu is generated from any heading formats used in the page.', + 'page_contents' => 'Sidans innehåll', + 'page_contents_none' => 'Inga rubriker hittades i sidans innehåll.', + 'page_contents_info' => 'Innehållsmenyn genereras utifrån de rubrikformat som används på sidan.', 'page_tags' => 'Sidtaggar', 'chapter_tags' => 'Kapiteltaggar', 'book_tags' => 'Boktaggar', @@ -361,7 +361,7 @@ 'attachments_explain_instant_save' => 'Ändringar här sparas omgående.', 'attachments_upload' => 'Ladda upp fil', 'attachments_link' => 'Bifoga länk', - 'attachments_upload_drop' => 'Alternatively you can drag and drop a file here to upload it as an attachment.', + 'attachments_upload_drop' => 'Alternativt kan du dra och släppa en fil här för att ladda upp den som en bilaga.', 'attachments_set_link' => 'Ange länk', 'attachments_delete' => 'Är du säker på att du vill ta bort bilagan?', 'attachments_dropzone' => 'Släpp filer här för uppladdning', @@ -403,9 +403,9 @@ 'comment_add' => 'Lägg till kommentar', 'comment_none' => 'Inga kommentarer att visa', 'comment_placeholder' => 'Lämna en kommentar här', - 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', - 'comment_archived_threads' => 'Archived Threads', + 'comment_thread_count' => ':count kommentarstråd|:count kommentarstrådar', + 'comment_archived_count' => ':count arkiverad(e)', + 'comment_archived_threads' => 'Arkiverade trådar', 'comment_save' => 'Spara kommentar', 'comment_new' => 'Ny kommentar', 'comment_created' => 'kommenterade :createDiff', @@ -415,7 +415,7 @@ 'comment_created_success' => 'Kommentaren har sparats', 'comment_updated_success' => 'Kommentaren har uppdaterats', 'comment_archive_success' => 'Arkivera kommentar', - 'comment_unarchive_success' => 'Comment un-archived', + 'comment_unarchive_success' => 'Kommentar avarkiverad', 'comment_view' => 'Visa kommentar', 'comment_jump_to_thread' => 'Hoppa till tråd', 'comment_delete_confirm' => 'Är du säker på att du vill ta bort den här kommentaren?', @@ -452,12 +452,12 @@ // References 'references' => 'Referenser', 'references_none' => 'Det finns inga referenser kopplade till detta objekt.', - 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', + 'references_to_desc' => 'Nedan listas allt känt innehåll i systemet som länkar till detta objekt.', // Watch Options 'watch' => 'Följ', 'watch_title_default' => 'Standardinställningar', - 'watch_desc_default' => 'Revert watching to just your default notification preferences.', + 'watch_desc_default' => 'Återställ bevakning till enbart dina standardaviseringsinställningar.', 'watch_title_ignore' => 'Ignorera', 'watch_desc_ignore' => 'Ignorera samtliga meddelanden, även sådana som styrs av användarens egna inställningar.', 'watch_title_new' => 'Nya sidor', @@ -465,16 +465,16 @@ 'watch_title_updates' => 'Alla siduppdateringar', 'watch_desc_updates' => 'Meddela vid alla nya sidor och siduppdateringar.', 'watch_desc_updates_page' => 'Meddela alla siduppdateringar.', - 'watch_title_comments' => 'All Page Updates & Comments', + 'watch_title_comments' => 'Alla sidupdateringar och kommentarer', 'watch_desc_comments' => 'Meddela vid alla nya sidor, siduppdateringar och nya kommentarer.', 'watch_desc_comments_page' => 'Meddela vid siduppdateringar och nya kommentarer.', 'watch_change_default' => 'Ändra standardinställningar för meddelanden', 'watch_detail_ignore' => 'Ignorera meddelanden', - 'watch_detail_new' => 'Watching for new pages', - 'watch_detail_updates' => 'Watching new pages and updates', - 'watch_detail_comments' => 'Watching new pages, updates & comments', - 'watch_detail_parent_book' => 'Watching via parent book', - 'watch_detail_parent_book_ignore' => 'Ignoring via parent book', - 'watch_detail_parent_chapter' => 'Watching via parent chapter', - 'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter', + 'watch_detail_new' => 'Bevakar nya sidor', + 'watch_detail_updates' => 'Bevakar nya sidor och uppdateringar', + 'watch_detail_comments' => 'Bevakar nya sidor, uppdateringar och kommentarer', + 'watch_detail_parent_book' => 'Bevakar via överordnad bok', + 'watch_detail_parent_book_ignore' => 'Ignorerar via överordnad bok', + 'watch_detail_parent_chapter' => 'Bevakar via överordnat kapitel', + 'watch_detail_parent_chapter_ignore' => 'Ignorerar via överordnat kapitel', ]; diff --git a/lang/sv/errors.php b/lang/sv/errors.php index 0548f96fd25..7cbe9f6c561 100644 --- a/lang/sv/errors.php +++ b/lang/sv/errors.php @@ -10,7 +10,7 @@ // Auth 'error_user_exists_different_creds' => 'En användare med adressen :email finns redan.', - 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', + 'auth_pre_register_theme_prevention' => 'Användarkontot kunde inte registreras med de angivna uppgifterna', 'email_already_confirmed' => 'E-posten har redan bekräftats, prova att logga in.', 'email_confirmation_invalid' => 'Denna bekräftelsekod är inte giltig eller har redan använts. Vänligen prova att registrera dig på nytt.', 'email_confirmation_expired' => 'Denna bekräftelsekod har gått ut. Vi har skickat dig en ny.', @@ -51,18 +51,18 @@ 'image_upload_error' => 'Ett fel inträffade vid uppladdningen', 'image_upload_type_error' => 'Filtypen du försöker ladda upp är ogiltig', 'image_upload_replace_type' => 'Bilder som skall ersättas måste vara av samma filtyp', - 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.', - 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.', + 'image_upload_memory_limit' => 'Det gick inte att hantera bilduppladdningen och/eller skapa miniatyrbilder på grund av begränsade systemresurser.', + 'image_thumbnail_memory_limit' => 'Det gick inte att skapa bildstorleksvarianter på grund av begränsade systemresurser.', 'image_gallery_thumbnail_memory_limit' => 'Misslyckades att skapa galleriminiatyrer på grund av otillräckliga systemresurser.', - 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.', + 'drawing_data_not_found' => 'Ritningsdata kunde inte laddas. Ritningen kanske inte längre finns, eller så har du inte behörighet att komma åt den.', // Attachments 'attachment_not_found' => 'Bilagan hittades ej', - 'attachment_upload_error' => 'An error occurred uploading the attachment file', + 'attachment_upload_error' => 'Ett fel uppstod vid uppladdning av bilagan', // Pages 'page_draft_autosave_fail' => 'Kunde inte spara utkastet. Kontrollera att du är ansluten till internet.', - 'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content', + 'page_draft_delete_fail' => 'Det gick inte att radera utkastet och hämta det sparade innehållet för aktuell sida', 'page_custom_home_deletion' => 'Det går inte att ta bort sidan medan den används som startsida', // Entities @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Du kan inte ta bort den enda admin-användaren', 'users_cannot_delete_guest' => 'Du kan inte ta bort gästanvändaren', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Kunde inte skapa användare eftersom inbjudningsmejlet inte kunde skickas', // Roles 'role_cannot_be_edited' => 'Den här rollen kan inte redigeras', @@ -108,8 +108,8 @@ // Import 'import_zip_cant_read' => 'Kunde inte läsa ZIP-filen.', 'import_zip_cant_decode_data' => 'Kunde inte hitta och avkoda ZIP data.json innehåll.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', + 'import_zip_no_data' => 'ZIP-filens data innehåller inte förväntat bok-, kapitel- eller sidinnehåll.', + 'import_zip_data_too_large' => 'Innehållet i ZIP-filens data.json överskrider den konfigurerade maxgränsen för uppladdning i applikationen.', 'import_validation_failed' => 'ZIP-filen kunde inte valideras med fel:', 'import_zip_failed_notification' => 'Det gick inte att importera ZIP-fil.', 'import_perms_books' => 'Du saknar behörighet att skapa böcker.', @@ -125,7 +125,7 @@ 'api_incorrect_token_secret' => 'Hemligheten för den angivna API-token är felaktig', 'api_user_no_api_permission' => 'Ägaren av den använda API-token har inte behörighet att göra API-anrop', 'api_user_token_expired' => 'Den använda auktoriseringstoken har löpt ut', - 'api_cookie_auth_only_get' => 'Only GET requests are allowed when using the API with cookie-based authentication', + 'api_cookie_auth_only_get' => 'Endast GET-förfrågningar är tillåtna vid API-användning med cookiebaserad autentisering', // Settings & Maintenance 'maintenance_test_email_failure' => 'Ett fel uppstod när ett test mail skulle skickas:', diff --git a/lang/sv/notifications.php b/lang/sv/notifications.php index 19933c049cb..cf1a87cf656 100644 --- a/lang/sv/notifications.php +++ b/lang/sv/notifications.php @@ -11,8 +11,8 @@ 'updated_page_subject' => 'Uppdaterad sida: :pageName', 'updated_page_intro' => 'En sida har blivit uppdaterad i :appName:', 'updated_page_debounce' => 'För att förhindra en massa notiser, så kommer det inte skickas nya notiser på ett tag för ytterligare ändringar till denna sida av samma skribent.', - 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', - 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', + 'comment_mention_subject' => 'Du har blivit nämnd i en kommentar på sidan: :pageName', + 'comment_mention_intro' => 'Du har blivit nämnd i en kommentar på :appName:', 'detail_page_name' => 'Sidonamn:', 'detail_page_path' => 'Sidosökväg:', diff --git a/lang/sv/preferences.php b/lang/sv/preferences.php index 7ebf2681350..80dc907184e 100644 --- a/lang/sv/preferences.php +++ b/lang/sv/preferences.php @@ -8,45 +8,45 @@ 'my_account' => 'Mitt Konto', 'shortcuts' => 'Genvägar', - 'shortcuts_interface' => 'UI Shortcut Preferences', - 'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.', - 'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.', - 'shortcuts_toggle_label' => 'Keyboard shortcuts enabled', - 'shortcuts_section_navigation' => 'Navigation', - 'shortcuts_section_actions' => 'Common Actions', + 'shortcuts_interface' => 'Inställningar för UI-genvägar', + 'shortcuts_toggle_desc' => 'Här kan du aktivera eller inaktivera tangentbordsgenvägar för systemgränssnittet, som används för navigering och åtgärder.', + 'shortcuts_customize_desc' => 'Du kan anpassa varje genväg nedan. Tryck bara på önskad tangentkombination efter att ha valt inmatningsfältet för en genväg.', + 'shortcuts_toggle_label' => 'Tangentbordsgenvägar aktiverade', + 'shortcuts_section_navigation' => 'Navigering', + 'shortcuts_section_actions' => 'Vanliga åtgärder', 'shortcuts_save' => 'Spara genvägar', - 'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.', - 'shortcuts_update_success' => 'Shortcut preferences have been updated!', - 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.', + 'shortcuts_overlay_desc' => 'Obs: När genvägar är aktiverade finns en hjälpöverlagring tillgänglig genom att trycka på "?", vilken markerar de tillgängliga genvägarna för åtgärder som för närvarande syns på skärmen.', + 'shortcuts_update_success' => 'Genvägsinställningarna har uppdaterats!', + 'shortcuts_overview_desc' => 'Hantera tangentbordsgenvägar som du kan använda för att navigera i systemets användargränssnitt.', - 'notifications' => 'Notification Preferences', - 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', - 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', - 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', - 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', - 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', - 'notifications_save' => 'Save Preferences', - 'notifications_update_success' => 'Notification preferences have been updated!', - 'notifications_watched' => 'Watched & Ignored Items', - 'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', + 'notifications' => 'Aviseringsinställningar', + 'notifications_desc' => 'Kontrollera de e-postaviseringar du får när viss aktivitet utförs i systemet.', + 'notifications_opt_own_page_changes' => 'Meddela vid ändringar på sidor jag äger', + 'notifications_opt_own_page_comments' => 'Meddela vid kommentarer på sidor jag äger', + 'notifications_opt_comment_mentions' => 'Meddela när jag blir nämnd i en kommentar', + 'notifications_opt_comment_replies' => 'Meddela vid svar på mina kommentarer', + 'notifications_save' => 'Spara inställningar', + 'notifications_update_success' => 'Aviseringsinställningarna har uppdaterats!', + 'notifications_watched' => 'Bevakade och ignorerade objekt', + 'notifications_watched_desc' => 'Nedan visas de objekt som har anpassade bevakningsinställningar. För att uppdatera dina inställningar för dessa, öppna objektet och hitta bevakningsalternativen i sidopanelen.', - 'auth' => 'Access & Security', - 'auth_change_password' => 'Change Password', - 'auth_change_password_desc' => 'Change the password you use to log-in to the application. This must be at least 8 characters long.', + 'auth' => 'Åtkomst och säkerhet', + 'auth_change_password' => 'Ändra lösenord', + 'auth_change_password_desc' => 'Ändra lösenordet du använder för att logga in i applikationen. Det måste vara minst 8 tecken långt.', 'auth_change_password_success' => 'Lösenordet har uppdaterats!', 'profile' => 'Profildetaljer', - 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.', + 'profile_desc' => 'Hantera detaljerna för ditt konto som representerar dig för andra användare, utöver de uppgifter som används för kommunikation och systempersonalisering.', 'profile_view_public' => 'Visa publik profil', - 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.', - 'profile_email_desc' => 'This email will be used for notifications and, depending on active system authentication, system access.', - 'profile_email_no_permission' => 'Unfortunately you don\'t have permission to change your email address. If you want to change this, you\'d need to ask an administrator to change this for you.', - 'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.', - 'profile_admin_options' => 'Administrator Options', - 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', + 'profile_name_desc' => 'Konfigurera ditt visningsnamn som kommer att vara synligt för andra användare i systemet genom den aktivitet du utför och det innehåll du äger.', + 'profile_email_desc' => 'Denna e-postadress kommer att användas för aviseringar och beroende på aktiv systemautentisering, för systemåtkomst.', + 'profile_email_no_permission' => 'Tyvärr har du inte behörighet att ändra din e-postadress. Om du vill ändra detta behöver du be en administratör att göra det åt dig.', + 'profile_avatar_desc' => 'Välj en bild som kommer att representera dig för andra i systemet. Helst bör bilden vara kvadratisk och cirka 256px bred och hög.', + 'profile_admin_options' => 'Administratörsalternativ', + 'profile_admin_options_desc' => 'Ytterligare inställningar på administratörsnivå, till exempel för att hantera rolltilldelningar, hittar du för ditt användarkonto under "Inställningar > Användare" i applikationen.', 'delete_account' => 'Radera konto', 'delete_my_account' => 'Radera mitt konto', - 'delete_my_account_desc' => 'This will fully delete your user account from the system. You will not be able to recover this account or revert this action. Content you\'ve created, such as created pages and uploaded images, will remain.', - 'delete_my_account_warning' => 'Are you sure you want to delete your account?', + 'delete_my_account_desc' => 'Detta kommer permanent radera ditt användarkonto från systemet. Du kommer inte kunna återställa detta konto eller ångra denna åtgärd. Innehåll du har skapat, till exempel skapade sidor och uppladdade bilder, kommer att finnas kvar.', + 'delete_my_account_warning' => 'Är du säker på att du vill radera ditt konto?', ]; diff --git a/lang/sv/settings.php b/lang/sv/settings.php index bf9968270ab..2968183dd8f 100644 --- a/lang/sv/settings.php +++ b/lang/sv/settings.php @@ -16,8 +16,8 @@ 'app_customization' => 'Sidanpassning', 'app_features_security' => 'Funktioner och säkerhet', 'app_name' => 'Applikationsnamn', - 'app_name_desc' => 'Namnet visas i sidhuvdet och i eventuella mail.', - 'app_name_header' => 'Visa applikationsnamn i sidhuvudet?', + 'app_name_desc' => 'Namnet visas i sidhuvudet och i eventuella mejl.', + 'app_name_header' => 'Visa applikationsnamn i sidhuvudet', 'app_public_access' => 'Offentlig åtkomst', 'app_public_access_desc' => 'Om du aktiverar detta alternativ låter du icke inloggade besökare komma åt innehåll på din sida', 'app_public_access_desc_guest' => 'Åtkomst för icke inloggade besökare kan styras via användaren "Guest".', @@ -75,36 +75,36 @@ 'reg_confirm_restrict_domain_placeholder' => 'Ingen begränsning inställd', // Sorting Settings - 'sorting' => 'Lists & Sorting', - 'sorting_book_default' => 'Default Book Sort Rule', + 'sorting' => 'Listor och sortering', + 'sorting_book_default' => 'Standardsorteringsregel för böcker', 'sorting_book_default_desc' => 'Välj standard sorteringsregel som skall tillämpas på nya böcker. Detta påverkar inte befintliga böcker och kan åsidosättas per bok.', 'sorting_rules' => 'Sorteringsregler', - 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + 'sorting_rules_desc' => 'Detta är fördefinierade sorteringsåtgärder som kan tillämpas på innehåll i systemet.', 'sort_rule_assigned_to_x_books' => 'Tilldelad till :count bok|Tilldelad till :count böcker', 'sort_rule_create' => 'Skapa sorteringsregel', 'sort_rule_edit' => 'Redigera sorteringsregel', 'sort_rule_delete' => 'Ta bort sorteringsregel', - 'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.', - 'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?', - 'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?', + 'sort_rule_delete_desc' => 'Ta bort denna sorteringsregel från systemet. Böcker som använder denna sortering kommer att återgå till manuell sortering.', + 'sort_rule_delete_warn_books' => 'Denna sorteringsregel används för närvarande på :count bok/böcker. Är du säker på att du vill radera den?', + 'sort_rule_delete_warn_default' => 'Denna sorteringsregel används för närvarande som standard för böcker. Är du säker på att du vill radera den?', 'sort_rule_details' => 'Detaljer för sorteringsregler', - 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.', - 'sort_rule_operations' => 'Sort Operations', - 'sort_rule_operations_desc' => 'Configure the sort actions to be performed 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_details_desc' => 'Ange ett namn för denna sorteringsregel, vilket kommer att visas i listor när användare väljer en sortering.', + 'sort_rule_operations' => 'Sorteringsåtgärder', + 'sort_rule_operations_desc' => 'Konfigurera sorteringsåtgärderna som ska utföras genom att flytta dem från listan över tillgängliga åtgärder. Vid användning kommer åtgärderna att tillämpas i ordning, uppifrån och ner. Alla ändringar som görs här kommer att tillämpas på alla tilldelade böcker vid sparande.', 'sort_rule_available_operations' => 'Tillgängliga åtgärder', - 'sort_rule_available_operations_empty' => 'No operations remaining', - 'sort_rule_configured_operations' => 'Configured Operations', - 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', - 'sort_rule_op_asc' => '(Asc)', - 'sort_rule_op_desc' => '(Desc)', + 'sort_rule_available_operations_empty' => 'Inga åtgärder kvar', + 'sort_rule_configured_operations' => 'Konfigurerade åtgärder', + 'sort_rule_configured_operations_empty' => 'Dra/lägg till åtgärder från listan "Tillgängliga åtgärder"', + 'sort_rule_op_asc' => '(Stigande)', + 'sort_rule_op_desc' => '(Fallande)', 'sort_rule_op_name' => 'Namn - Alfabetisk ordning', 'sort_rule_op_name_numeric' => 'Namn - Numerisk ordning', 'sort_rule_op_created_date' => 'Datum skapat', 'sort_rule_op_updated_date' => 'Datum uppdaterat', - 'sort_rule_op_chapters_first' => 'Chapters First', - 'sort_rule_op_chapters_last' => 'Chapters Last', - '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.', + 'sort_rule_op_chapters_first' => 'Kapitel först', + 'sort_rule_op_chapters_last' => 'Kapitel sist', + 'sorting_page_limits' => 'Gränser för antal objekt per sida', + 'sorting_page_limits_desc' => 'Ställ in hur många objekt som ska visas per sida i olika listor i systemet. Vanligtvis är ett lägre antal mer prestandaeffektivt, medan ett högre antal minskar behovet av att klicka sig igenom flera sidor. Det rekommenderas att använda en multipel av 6.', // Maintenance settings 'maint' => 'Underhåll', @@ -141,7 +141,7 @@ 'recycle_bin_contents_empty' => 'Papperskorgen är för närvarande tom', 'recycle_bin_empty' => 'Töm papperskorgen', 'recycle_bin_empty_confirm' => 'Detta kommer permanent att förstöra alla objekt i papperskorgen inklusive innehåll som finns i varje objekt. Är du säker du vill tömma papperskorgen?', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_confirm' => 'Denna åtgärd kommer att permanent radera detta objekt från systemet, tillsammans med eventuella underliggande element som listas nedan, och du kommer inte att kunna återställa detta innehåll. Är du säker på att du vill permanent radera detta objekt?', 'recycle_bin_destroy_list' => 'Objekt som ska förstöras', 'recycle_bin_restore_list' => 'Objekt som ska återställas', 'recycle_bin_restore_confirm' => 'Denna åtgärd kommer att återställa det raderade objektet, inklusive alla underordnade element, till deras ursprungliga plats. Om den ursprungliga platsen har tagits bort sedan dess, och är nu i papperskorgen, kommer det överordnade objektet också att behöva återställas.', @@ -168,11 +168,11 @@ // Role Settings 'roles' => 'Roller', 'role_user_roles' => 'Användarroller', - 'roles_index_desc' => 'Roles are used to group users & provide system permission to their members. When a user is a member of multiple roles the privileges granted will stack and the user will inherit all abilities.', - 'roles_x_users_assigned' => ':count user assigned|:count users assigned', - 'roles_x_permissions_provided' => ':count permission|:count permissions', - 'roles_assigned_users' => 'Assigned Users', - 'roles_permissions_provided' => 'Provided Permissions', + 'roles_index_desc' => 'Roller används för att gruppera användare och ge deras medlemmar systembehörigheter. När en användare är medlem i flera roller läggs behörigheterna samman och användaren ärver alla rättigheter.', + 'roles_x_users_assigned' => ':count användare tilldelad|:count användare tilldelade', + 'roles_x_permissions_provided' => ':count behörighet|:count behörigheter', + 'roles_assigned_users' => 'Tilldelade användare', + 'roles_permissions_provided' => 'Tillhandahållna behörigheter', 'role_create' => 'Skapa ny roll', 'role_delete' => 'Ta bort roll', 'role_delete_confirm' => 'Rollen med namn \':roleName\' kommer att tas bort.', @@ -194,27 +194,27 @@ 'role_access_api' => 'Åtkomst till systemets API', 'role_manage_settings' => 'Hantera appinställningar', 'role_export_content' => 'Exportera innehåll', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Importera innehåll', 'role_editor_change' => 'Ändra sidredigerare', - 'role_notifications' => 'Receive & manage notifications', - 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', + 'role_notifications' => 'Ta emot och hantera aviseringar', + 'role_permission_note_users_and_roles' => 'Dessa behörigheter kommer i praktiken även att ge synlighet och sökmöjlighet för användare och roller i systemet.', 'role_asset' => 'Tillgång till innehåll', 'roles_system_warning' => 'Var medveten om att åtkomst till någon av ovanstående tre behörigheter kan tillåta en användare att ändra sina egna rättigheter eller andras rättigheter i systemet. Tilldela endast roller med dessa behörigheter till betrodda användare.', 'role_asset_desc' => 'Det här är standardinställningarna för allt innehåll i systemet. Eventuella anpassade rättigheter på böcker, kapitel och sidor skriver över dessa inställningar.', 'role_asset_admins' => 'Administratörer har automatisk tillgång till allt innehåll men dessa alternativ kan visa och dölja vissa gränssnittselement', 'role_asset_image_view_note' => 'Detta avser synlighet inom bildhanteraren. Faktisk åtkomst för uppladdade bildfiler kommer att bero på alternativ för bildlagring.', - 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', + 'role_asset_users_note' => 'Dessa behörigheter kommer i praktiken även att ge synlighet och sökmöjlighet för användare i systemet.', '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_controlled_by_page_delete' => 'Styrs av behörigheter för radering av sidor', 'role_save' => 'Spara roll', 'role_users' => 'Användare med denna roll', 'role_users_none' => 'Inga användare tillhör den här rollen', // Users 'users' => 'Användare', - 'users_index_desc' => 'Create & manage individual user accounts within the system. User accounts are used for login and attribution of content & activity. Access permissions are primarily role-based but user content ownership, among other factors, may also affect permissions & access.', + 'users_index_desc' => 'Skapa och hantera enskilda användarkonton i systemet. Användarkonton används för inloggning och för att tillskriva innehåll och aktivitet en användare. Åtkomstbehörigheter är i huvudsak rollbaserade, men användarens innehållsägarskap, bland andra faktorer, kan också påverka behörigheter och åtkomst.', 'user_profile' => 'Användarprofil', 'users_add_new' => 'Lägg till användare', 'users_search' => 'Sök användare', @@ -229,8 +229,8 @@ 'users_send_invite_text' => 'Du kan välja att skicka denna användare ett e-postmeddelande som tillåter dem att ställa in sitt eget lösenord, eller så kan du ställa in deras lösenord själv.', 'users_send_invite_option' => 'Skicka e-post med inbjudan', 'users_external_auth_id' => 'Externt ID för autentisering', - 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', - 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', + 'users_external_auth_id_desc' => 'När ett externt autentiseringssystem används (såsom SAML2, OIDC eller LDAP) är detta det ID som länkar detta BookStack-konto till kontot i autentiseringssystemet. Du kan bortse från detta fält om standardautentisering via e-post används.', + 'users_password_warning' => 'Dessa behörigheter kommer i praktiken även att ge synlighet och sökmöjlighet för användare i systemet.', 'users_system_public' => 'Den här användaren representerar eventuella gäster som använder systemet. Den kan inte användas för att logga in utan tilldeles automatiskt.', 'users_delete' => 'Ta bort användare', 'users_delete_named' => 'Ta bort användaren :userName', @@ -246,7 +246,7 @@ 'users_preferred_language' => 'Föredraget språk', 'users_preferred_language_desc' => 'Det här alternativet kommer att ändra det språk som används i användargränssnittet. Detta påverkar inget användarskapat innehåll.', 'users_social_accounts' => 'Anslutna konton', - 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', + 'users_social_accounts_desc' => 'Dessa behörigheter kommer i praktiken även att ge synlighet och sökmöjlighet för användare och roller i systemet.', 'users_social_accounts_info' => 'Här kan du ansluta dina andra konton för snabbare och smidigare inloggning. Om du kopplar från en tjänst här kommer de behörigheter som tidigare givits inte att tas bort - ta bort behörigheter genom att logga in på ditt konto på tjänsten i fråga.', 'users_social_connect' => 'Anslut konto', 'users_social_disconnect' => 'Koppla från konto', @@ -255,7 +255,7 @@ 'users_social_connected' => ':socialAccount har kopplats till ditt konto.', 'users_social_disconnected' => ':socialAccount har kopplats bort från ditt konto.', 'users_api_tokens' => 'API-nyckel', - 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_desc' => 'Ta emot och hantera aviseringar', 'users_api_tokens_none' => 'Inga API-tokens har skapats för den här användaren', 'users_api_tokens_create' => 'Skapa token', 'users_api_tokens_expires' => 'Förfaller', @@ -264,9 +264,9 @@ 'users_mfa_desc' => 'Konfigurera multifaktorsautentisering som ett extra skydd för ditt konto.', 'users_mfa_x_methods' => ':count metod konfigurerad|:count metoder konfigurerade', 'users_mfa_configure' => 'Konfigurera metoder', - '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?', + 'users_mfa_reset' => 'Återställ metoder för multifaktorautentisering', + 'users_mfa_reset_desc' => 'Detta kommer att återställa och rensa alla konfigurerade metoder för multifaktorautentisering för denna användare. Om multifaktorautentisering krävs av någon av deras roller kommer de att uppmanas att konfigurera nya metoder vid nästa inloggning.', + 'users_mfa_reset_confirm' => 'Är du säker på att du vill återställa multifaktorautentisering för denna användare?', // API Tokens 'user_api_token_create' => 'Skapa API-nyckel', @@ -288,8 +288,8 @@ // Webhooks 'webhooks' => 'Webhooks', - 'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.', - 'webhooks_x_trigger_events' => ':count trigger event|:count trigger events', + 'webhooks_index_desc' => 'Webhooks är ett sätt att skicka data till externa URLer när vissa åtgärder och händelser inträffar i systemet, vilket möjliggör händelsebaserad integration med externa plattformar såsom meddelande- eller aviseringssystem.', + 'webhooks_x_trigger_events' => ':count utlösande händelse|:count utlösande händelser', 'webhooks_create' => 'Skapa ny webhook', 'webhooks_none_created' => 'Inga webhooks har skapats än.', 'webhooks_edit' => 'Redigera webhook', @@ -317,7 +317,7 @@ // Licensing 'licenses' => 'Licenser', - 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', + 'licenses_desc' => 'Denna sida beskriver licensinformation för BookStack samt de projekt och bibliotek som används inom BookStack. Många av de listade projekten används eventuellt bara i ett utvecklingssammanhang.', 'licenses_bookstack' => 'BookStack licens', 'licenses_php' => 'Licenser för PHP-bibliotek', 'licenses_js' => 'Licenser för JavaScript-bibliotek', diff --git a/lang/sv/validation.php b/lang/sv/validation.php index b0c1f7a1bb9..4f9298ba33d 100644 --- a/lang/sv/validation.php +++ b/lang/sv/validation.php @@ -105,11 +105,11 @@ 'url' => 'Formatet på :attribute är ogiltigt.', 'uploaded' => 'Filen kunde inte laddas upp. Servern kanske inte tillåter filer med denna storlek.', - '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 måste referera till en fil inom ZIP-filen.', + 'zip_file_size' => 'Filen :attribute får inte överstiga :size MB.', + 'zip_file_mime' => ':attribute måste referera till en fil av typen :validTypes, hittade :foundType.', + 'zip_model_expected' => 'Dataobjekt förväntades men ":type" hittades.', + 'zip_unique' => ':attribute måste referera till en fil inom ZIP-filen.', // Custom validation lines 'custom' => [ diff --git a/lang/tr/auth.php b/lang/tr/auth.php index f8942a4dfc3..907487baff9 100644 --- a/lang/tr/auth.php +++ b/lang/tr/auth.php @@ -8,7 +8,7 @@ 'failed' => 'Girdiğiniz bilgiler kayıtlarımızla uyuşmuyor.', 'throttle' => 'Çok fazla giriş yapmaya çalıştınız. Lütfen :seconds saniye içinde tekrar deneyin.', - 'mfa_throttle' => 'Too many multi-factor verification attempts. Please try again in :seconds seconds.', + 'mfa_throttle' => 'Çok fazla çok faktörlü doğrulama denemesi yapıldı. Lütfen :seconds içinde tekrar deneyin.', // Login & Register 'sign_up' => 'Kaydol', @@ -92,7 +92,7 @@ 'mfa_option_totp_title' => 'Mobil Uygulama', 'mfa_option_totp_desc' => 'Çok aşamalı kimlik doğrulamayı kullanabilmek için Google Authenticator, Authy veya Microsoft Authenticator gibi TOTP destekleyen bir mobil uygulamaya ihtiyacınız olacaktır.', 'mfa_option_backup_codes_title' => 'Yedekleme Kodları', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Kimliğinizi doğrulamak için giriş yaparken kullanacağınız tek kullanımlık yedek kodlar oluşturur. Bunları güvenli ve sağlam bir yerde sakladığınızdan emin olun.', 'mfa_gen_confirm_and_enable' => 'Onayla ve aktive et', 'mfa_gen_backup_codes_title' => 'Yedekleme Kodları Kurulumu', 'mfa_gen_backup_codes_desc' => 'Aşağıdaki kod listesini güvenli bir yerde sakla. Sisteme giriş yaparken kodlardan birini ikinci bir kimlik doğrulama mekanizması olarak kullanabileceksin.', diff --git a/lang/tr/common.php b/lang/tr/common.php index 5b5e753873a..14baba8b2b1 100644 --- a/lang/tr/common.php +++ b/lang/tr/common.php @@ -20,7 +20,7 @@ 'description' => 'Açıklama', 'role' => 'Rol', 'cover_image' => 'Kapak resmi', - 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', + 'cover_image_description' => 'Bu görüntü yaklaşık 440x250px olmalıdır, ancak kullanıcı arayüzüne farklı senaryolarda uyacak şekilde esnek ölçeklendirilip kırpılacak, bu yüzden gerçek ekran boyutları farklı olacaktır.', // Actions 'actions' => 'İşlemler', diff --git a/lang/tr/editor.php b/lang/tr/editor.php index c020c82fddf..79a82fdf61b 100644 --- a/lang/tr/editor.php +++ b/lang/tr/editor.php @@ -13,7 +13,7 @@ 'cancel' => 'İptal', 'save' => 'Kaydet', 'close' => 'Kapat', - 'apply' => 'Apply', + 'apply' => 'Uygula', 'undo' => 'Geri al', 'redo' => 'Yeniden yap', 'left' => 'Sol', From de6e6f3dd97cafbe960f29a858c7a456b704d629 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Jul 2026 09:56:08 +0100 Subject: [PATCH 293/398] Deps: Updated PHP package versions --- composer.lock | 331 ++++++++++++++++++++++++-------------------------- 1 file changed, 157 insertions(+), 174 deletions(-) diff --git a/composer.lock b/composer.lock index 685d88f524d..3576e38ca2c 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.387.1", + "version": "3.389.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "2e1a16e9c87f2a069aa8a0a14a314dd148de21e1" + "reference": "784e0fb95e752e55c4654b5800b900c78f6d3990" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2e1a16e9c87f2a069aa8a0a14a314dd148de21e1", - "reference": "2e1a16e9c87f2a069aa8a0a14a314dd148de21e1", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/784e0fb95e752e55c4654b5800b900c78f6d3990", + "reference": "784e0fb95e752e55c4654b5800b900c78f6d3990", "shasum": "" }, "require": { @@ -79,9 +79,9 @@ "ext-json": "*", "ext-pcre": "*", "ext-simplexml": "*", - "guzzlehttp/guzzle": "^7.4.5", - "guzzlehttp/promises": "^2.0", - "guzzlehttp/psr7": "^2.4.5", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", "mtdowling/jmespath.php": "^2.9.1", "php": ">=8.1", "psr/http-message": "^1.0 || ^2.0", @@ -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.387.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.389.2" }, - "time": "2026-07-01T18:10:42+00:00" + "time": "2026-07-28T18:10:25+00:00" }, { "name": "bacon/bacon-qr-code", @@ -635,16 +635,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.1.5", + "version": "v3.1.6", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", "shasum": "" }, "require": { @@ -693,9 +693,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" }, - "time": "2026-03-03T13:54:37+00:00" + "time": "2026-07-20T12:29:38+00:00" }, { "name": "dompdf/php-font-lib", @@ -1181,22 +1181,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.13.1", + "version": "7.15.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d" + "reference": "744101956d78b7c1384d0cbf379db13e859167bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/55901a76dfd2006a0cc012b9e3c5b487f796478d", - "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf", + "reference": "744101956d78b7c1384d0cbf379db13e859167bf", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.3", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1208,8 +1208,8 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.6", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1289,7 +1289,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.13.1" + "source": "https://github.com/guzzle/guzzle/tree/7.15.2" }, "funding": [ { @@ -1305,20 +1305,20 @@ "type": "tidelift" } ], - "time": "2026-06-29T20:14:18+00:00" + "time": "2026-07-26T23:23:20+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { @@ -1373,7 +1373,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -1389,20 +1389,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.3", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", "shasum": "" }, "require": { @@ -1492,7 +1492,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.3" + "source": "https://github.com/guzzle/psr7/tree/2.13.0" }, "funding": [ { @@ -1508,20 +1508,20 @@ "type": "tidelift" } ], - "time": "2026-06-23T15:21:08+00:00" + "time": "2026-07-16T22:23:49+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.8", + "version": "v1.0.10", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd" + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/9c19128923b05a5d7355e5d2318d7808b7e33bbd", - "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", "shasum": "" }, "require": { @@ -1578,7 +1578,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.8" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" }, "funding": [ { @@ -1594,7 +1594,7 @@ "type": "tidelift" } ], - "time": "2026-06-23T13:02:23+00:00" + "time": "2026-07-17T13:53:03+00:00" }, { "name": "intervention/gif", @@ -1809,16 +1809,16 @@ }, { "name": "laravel/framework", - "version": "v12.62.0", + "version": "v12.64.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f7e61eb1e0e06a38996802b769bce9127aec227c" + "reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f7e61eb1e0e06a38996802b769bce9127aec227c", - "reference": "f7e61eb1e0e06a38996802b769bce9127aec227c", + "url": "https://api.github.com/repos/laravel/framework/zipball/727a8ea2949c23ca8b5316b86a00984b6017b7a0", + "reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0", "shasum": "" }, "require": { @@ -2027,7 +2027,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-06-09T13:50:13+00:00" + "time": "2026-07-14T14:25:37+00:00" }, { "name": "laravel/prompts", @@ -2090,16 +2090,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.13", + "version": "v2.0.15", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", "shasum": "" }, "require": { @@ -2147,20 +2147,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-04-16T14:03:50+00:00" + "time": "2026-07-21T16:49:22+00:00" }, { "name": "laravel/socialite", - "version": "v5.28.0", + "version": "v5.29.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "4c131ff4b24d8881a9c8fe4eecb5ffeff9803f26" + "reference": "cd343a5841f02292af119ee607edc71300c9ae4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/4c131ff4b24d8881a9c8fe4eecb5ffeff9803f26", - "reference": "4c131ff4b24d8881a9c8fe4eecb5ffeff9803f26", + "url": "https://api.github.com/repos/laravel/socialite/zipball/cd343a5841f02292af119ee607edc71300c9ae4f", + "reference": "cd343a5841f02292af119ee607edc71300c9ae4f", "shasum": "" }, "require": { @@ -2219,7 +2219,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-06-12T03:24:05+00:00" + "time": "2026-07-01T13:50:23+00:00" }, { "name": "laravel/tinker", @@ -2289,16 +2289,16 @@ }, { "name": "league/commonmark", - "version": "2.8.2", + "version": "2.8.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", "shasum": "" }, "require": { @@ -2320,8 +2320,8 @@ "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4 || ^2.0", "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", "scrutinizer/ocular": "^1.8.1", "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", @@ -2392,7 +2392,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T13:16:38+00:00" + "time": "2026-07-12T15:29:16+00:00" }, { "name": "league/config", @@ -2478,16 +2478,16 @@ }, { "name": "league/flysystem", - "version": "3.35.1", + "version": "3.35.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c" + "reference": "b277b5dc3d56650b68904117124e79c851e12376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f23af6c5aafd958a7593029a271d77baf5ed793c", - "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", "shasum": "" }, "require": { @@ -2555,22 +2555,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.1" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" }, - "time": "2026-06-25T06:52:23+00:00" + "time": "2026-07-06T14:42:07+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.35.1", + "version": "3.35.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94" + "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94", - "reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4", + "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4", "shasum": "" }, "require": { @@ -2610,9 +2610,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.1" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2" }, - "time": "2026-06-25T06:51:08+00:00" + "time": "2026-07-01T23:25:49+00:00" }, { "name": "league/flysystem-local", @@ -2754,16 +2754,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -2773,7 +2773,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -2794,7 +2794,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -2806,7 +2806,7 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { "name": "league/oauth1-client", @@ -3303,16 +3303,16 @@ }, { "name": "mtdowling/jmespath.php", - "version": "2.9.1", + "version": "2.9.2", "source": { "type": "git", "url": "https://github.com/jmespath/jmespath.php.git", - "reference": "9c208ba27ae7d90853c288b3795d6702eb251d34" + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9c208ba27ae7d90853c288b3795d6702eb251d34", - "reference": "9c208ba27ae7d90853c288b3795d6702eb251d34", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", "shasum": "" }, "require": { @@ -3363,22 +3363,22 @@ ], "support": { "issues": "https://github.com/jmespath/jmespath.php/issues", - "source": "https://github.com/jmespath/jmespath.php/tree/2.9.1" + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2" }, - "time": "2026-06-11T10:43:56+00:00" + "time": "2026-07-06T18:56:19+00:00" }, { "name": "nesbot/carbon", - "version": "3.13.0", + "version": "3.13.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", - "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", "shasum": "" }, "require": { @@ -3470,7 +3470,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T13:49:15+00:00" + "time": "2026-07-09T18:23:49+00:00" }, { "name": "nette/schema", @@ -3541,16 +3541,16 @@ }, { "name": "nette/utils", - "version": "v4.1.4", + "version": "v4.1.5", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", "shasum": "" }, "require": { @@ -3570,7 +3570,7 @@ }, "suggest": { "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", "ext-json": "to use Nette\\Utils\\Json", "ext-mbstring": "to use Strings::lower() etc...", @@ -3626,26 +3626,25 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.4" + "source": "https://github.com/nette/utils/tree/v4.1.5" }, - "time": "2026-05-11T20:49:54+00:00" + "time": "2026-07-17T23:02:45+00:00" }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -3684,9 +3683,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "nunomaduro/termwind", @@ -6597,16 +6596,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -6655,7 +6654,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -6675,7 +6674,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-idn", @@ -7020,16 +7019,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", "shasum": "" }, "require": { @@ -7076,7 +7075,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { @@ -7096,7 +7095,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-php84", @@ -7180,16 +7179,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -7236,7 +7235,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -7256,7 +7255,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-uuid", @@ -8216,16 +8215,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.4", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", "shasum": "" }, "require": { @@ -8284,7 +8283,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" }, "funding": [ { @@ -8296,7 +8295,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-07-06T19:11:50+00:00" }, { "name": "voku/portable-ascii", @@ -8971,23 +8970,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.4", + "version": "v8.9.5", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.8 || ^8.0.8" + "symfony/console": "^7.4.14 || ^8.1.1" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -8995,12 +8994,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "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.3.0" + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" }, "type": "library", "extra": { @@ -9063,7 +9062,7 @@ "type": "patreon" } ], - "time": "2026-04-21T14:04:20+00:00" + "time": "2026-07-15T19:09:14+00:00" }, { "name": "phar-io/manifest", @@ -9185,11 +9184,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.3", + "version": "2.2.6", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4048833dd47b377287818841877fb3087289509c", - "reference": "4048833dd47b377287818841877fb3087289509c", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "reference": "a6e9b5a9420f6109c091e87d82683bd1a80b87ed", "shasum": "" }, "require": { @@ -9245,7 +9244,7 @@ "type": "github" } ], - "time": "2026-06-30T21:15:26+00:00" + "time": "2026-07-26T21:22:49+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9596,24 +9595,24 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.55", + "version": "11.5.56", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", @@ -9678,31 +9677,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:37:06+00:00" + "time": "2026-07-06T14:52:39+00:00" }, { "name": "sebastian/cli-parser", From c813c1b3628c0b6bd757c12cadaa56f50724117d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 30 Jul 2026 02:42:46 +0100 Subject: [PATCH 294/398] Meta: Updated translator list --- .github/translators.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/translators.txt b/.github/translators.txt index a2f6a957066..a6e01d0ed8f 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -548,3 +548,7 @@ brtbr :: German; German Informal Ricardo Covelo (covelo12) :: Portuguese Bojan Maksimovic (PolarniMeda) :: Serbian (Cyrillic) Dian Prawira (wiradian84) :: Indonesian +Tim (timakai) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal +dadda123 :: Swedish +Julien Muggli (JulienMuggli) :: French +nomoreshow :: Turkish From fa27485b2252e0d9ceb2fd10e588aa20e5cf70f9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 2 Aug 2026 16:06:51 +0100 Subject: [PATCH 295/398] Front End: Added page scroll progress indicator to back-to-top element --- resources/js/components/back-to-top.js | 79 +++++++++++++++++-- resources/sass/_components.scss | 34 ++++++-- resources/views/layouts/base.blade.php | 7 +- .../views/layouts/parts/back-to-top.blade.php | 15 ++++ 4 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 resources/views/layouts/parts/back-to-top.blade.php diff --git a/resources/js/components/back-to-top.js b/resources/js/components/back-to-top.js index 046e640d10a..9b319a3bdef 100644 --- a/resources/js/components/back-to-top.js +++ b/resources/js/components/back-to-top.js @@ -3,10 +3,15 @@ import {Component} from './component'; export class BackToTop extends Component { setup() { - this.button = this.$el; + this.container = this.$el; + this.button = this.$refs.button; + this.progress = this.$refs.progress; + this.progressPath = this.$refs.progressPath; + this.targetElem = document.getElementById('header'); this.showing = false; this.breakPoint = 1200; + this.isAnimating = false; if (document.body.classList.contains('flexbox')) { this.button.style.display = 'none'; @@ -15,23 +20,46 @@ export class BackToTop extends Component { this.button.addEventListener('click', this.scrollToTop.bind(this)); window.addEventListener('scroll', this.onPageScroll.bind(this)); + + this.setupProgressBar(); + } + + setupProgressBar() { + this.button.addEventListener('transitionstart', event => { + if (event.target !== this.button) return; + this.isAnimating = true; + this.renderProgressPath(); + }); + const stopAnimating = event => { + if (event.target !== this.button) return; + this.isAnimating = false; + }; + this.button.addEventListener('transitionend', stopAnimating); + this.button.addEventListener('transitioncancel', stopAnimating); + this.renderProgressPath(); } onPageScroll() { const scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0; if (!this.showing && scrollTopPos > this.breakPoint) { - this.button.style.display = 'block'; + this.container.display = 'block'; this.showing = true; setTimeout(() => { - this.button.style.opacity = 0.4; + this.container.style.opacity = '0.4'; }, 1); } else if (this.showing && scrollTopPos < this.breakPoint) { - this.button.style.opacity = 0; + this.container.style.opacity = '0'; this.showing = false; setTimeout(() => { - this.button.style.display = 'none'; + this.container.display = 'none'; }, 500); } + + if (this.showing) { + const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight; + const scrollTopPercent = (scrollTopPos / maxScrollTop) * 100; + this.updateProgress(scrollTopPercent); + } } scrollToTop() { @@ -55,4 +83,45 @@ export class BackToTop extends Component { requestAnimationFrame(setPos.bind(this)); } + renderProgressPath() { + const bounds = this.button.getBoundingClientRect(); + const progressInset = window.getComputedStyle(this.progress).insetInlineStart; + const offset = Math.abs(Number(progressInset.replace('px', '') || 0)); + const path = this.roundedRectPath(bounds.width, bounds.height, bounds.height / 2, offset); + this.progressPath.setAttribute('d', path); + if (this.isAnimating) { + window.requestAnimationFrame(this.renderProgressPath.bind(this)); + } + } + + updateProgress(percentComplete) { + if (percentComplete < 5) { + percentComplete = 5; + } + this.progressPath.setAttribute('stroke-dasharray', `${Math.ceil(percentComplete)} 100`); + } + + roundedRectPath(w, h, r, offset = 1.5) { + // Expand dimensions outward by the offset + const W = w + 2 * offset; + const H = h + 2 * offset; + + // The corner radius also grows by the offset + const R = Math.min(r + offset, W / 2, H / 2); + + return [ + `M ${W / 2} 0`, // start at top center + `H ${W - R}`, // line to top-right (before arc) + `A ${R} ${R} 0 0 1 ${W} ${R}`, // arc: top-right corner + `V ${H - R}`, // line down right edge + `A ${R} ${R} 0 0 1 ${W - R} ${H}`, // arc: bottom-right corner + `H ${R}`, // line across bottom edge + `A ${R} ${R} 0 0 1 ${0} ${H - R}`, // arc: bottom-left corner + `V ${R}`, // line up left edge + `A ${R} ${R} 0 0 1 ${R} ${0}`, // arc: top-left corner + `Z` // close path back to start + ].join(' '); + } + + } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 8608427d8e8..bc7a0ed8823 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1098,16 +1098,24 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { // Back to top link $btt-size: 40px; -.back-to-top { - background-color: var(--color-primary); +.back-to-top-container { position: fixed; bottom: vars.$m; right: vars.$l; + opacity: 0; + z-index: 999; + transition: opacity ease-in-out 180ms; + &:hover { + opacity: 1 !important; + } +} +.back-to-top { + background-color: var(--color-primary); padding: 5px 7px; cursor: pointer; color: #FFF; fill: #FFF; - svg { + .inner svg { width: math.div($btt-size, 1.5); height: math.div($btt-size, 1.5); margin-inline-end: 4px; @@ -1116,8 +1124,6 @@ $btt-size: 40px; height: $btt-size; border-radius: $btt-size; transition: all ease-in-out 180ms; - opacity: 0; - z-index: 999; overflow: hidden; &:hover { width: $btt-size*3.4; @@ -1125,6 +1131,7 @@ $btt-size: 40px; } .inner { width: $btt-size*3.4; + text-align: start; } span { position: relative; @@ -1133,6 +1140,23 @@ $btt-size: 40px; } } +.back-to-top-progress { + position: absolute; + top: -3px; + inset-inline-start: -3px; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; + path { + fill: none; + stroke: var(--color-primary); + stroke-width: 2px; + stroke-linecap: butt; + transition: stroke-dasharray ease-in-out 120ms; + } +} + // Sortable scroll boxes .scroll-box { list-style: none; diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index b868cb88804..90d987ccb56 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -61,12 +61,7 @@ class="@stack('body-class')">
    @include('layouts.parts.footer') - - + @include('layouts.parts.back-to-top') @if($cspNonce ?? false) diff --git a/resources/views/layouts/parts/back-to-top.blade.php b/resources/views/layouts/parts/back-to-top.blade.php new file mode 100644 index 00000000000..741859d1c35 --- /dev/null +++ b/resources/views/layouts/parts/back-to-top.blade.php @@ -0,0 +1,15 @@ +
    + + + + +
    \ No newline at end of file From 36fe7c375f4d6961724918fd4e06791fcd0242fd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 2 Aug 2026 16:15:16 +0100 Subject: [PATCH 296/398] JS: Converted back-to-top component to TS --- .../{back-to-top.js => back-to-top.ts} | 49 ++++++++++--------- .../views/layouts/parts/back-to-top.blade.php | 2 +- 2 files changed, 28 insertions(+), 23 deletions(-) rename resources/js/components/{back-to-top.js => back-to-top.ts} (78%) diff --git a/resources/js/components/back-to-top.js b/resources/js/components/back-to-top.ts similarity index 78% rename from resources/js/components/back-to-top.js rename to resources/js/components/back-to-top.ts index 9b319a3bdef..e47a0478acc 100644 --- a/resources/js/components/back-to-top.js +++ b/resources/js/components/back-to-top.ts @@ -2,19 +2,26 @@ import {Component} from './component'; export class BackToTop extends Component { - setup() { + private container!: HTMLElement; + private button!: HTMLElement; + private progress!: HTMLElement; + private progressPath!: SVGPathElement; + private targetElem!: HTMLElement; + + private showing: boolean = false; + private breakPoint: number = 1200; + private isAnimating: boolean = false; + + setup(): void { this.container = this.$el; this.button = this.$refs.button; this.progress = this.$refs.progress; - this.progressPath = this.$refs.progressPath; + this.progressPath = this.$refs.progressPath as unknown as SVGPathElement; - this.targetElem = document.getElementById('header'); - this.showing = false; - this.breakPoint = 1200; - this.isAnimating = false; + this.targetElem = document.getElementById('header') as HTMLElement; if (document.body.classList.contains('flexbox')) { - this.button.style.display = 'none'; + this.container.style.display = 'none'; return; } @@ -24,13 +31,13 @@ export class BackToTop extends Component { this.setupProgressBar(); } - setupProgressBar() { + private setupProgressBar(): void { this.button.addEventListener('transitionstart', event => { if (event.target !== this.button) return; this.isAnimating = true; this.renderProgressPath(); }); - const stopAnimating = event => { + const stopAnimating = (event: TransitionEvent) => { if (event.target !== this.button) return; this.isAnimating = false; }; @@ -39,10 +46,10 @@ export class BackToTop extends Component { this.renderProgressPath(); } - onPageScroll() { + private onPageScroll(): void { const scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0; if (!this.showing && scrollTopPos > this.breakPoint) { - this.container.display = 'block'; + this.container.style.display = 'block'; this.showing = true; setTimeout(() => { this.container.style.opacity = '0.4'; @@ -51,7 +58,7 @@ export class BackToTop extends Component { this.container.style.opacity = '0'; this.showing = false; setTimeout(() => { - this.container.display = 'none'; + this.container.style.display = 'none'; }, 500); } @@ -62,28 +69,28 @@ export class BackToTop extends Component { } } - scrollToTop() { + private scrollToTop(): void { const targetTop = this.targetElem.getBoundingClientRect().top; const scrollElem = document.documentElement.scrollTop ? document.documentElement : document.body; const duration = 300; const start = Date.now(); const scrollStart = this.targetElem.getBoundingClientRect().top; - function setPos() { + const setPos = () => { const percentComplete = (1 - ((Date.now() - start) / duration)); const target = Math.abs(percentComplete * scrollStart); if (percentComplete > 0) { scrollElem.scrollTop = target; - requestAnimationFrame(setPos.bind(this)); + requestAnimationFrame(setPos); } else { scrollElem.scrollTop = targetTop; } - } + }; - requestAnimationFrame(setPos.bind(this)); + requestAnimationFrame(setPos); } - renderProgressPath() { + private renderProgressPath(): void { const bounds = this.button.getBoundingClientRect(); const progressInset = window.getComputedStyle(this.progress).insetInlineStart; const offset = Math.abs(Number(progressInset.replace('px', '') || 0)); @@ -94,14 +101,14 @@ export class BackToTop extends Component { } } - updateProgress(percentComplete) { + private updateProgress(percentComplete: number): void { if (percentComplete < 5) { percentComplete = 5; } this.progressPath.setAttribute('stroke-dasharray', `${Math.ceil(percentComplete)} 100`); } - roundedRectPath(w, h, r, offset = 1.5) { + private roundedRectPath(w: number, h: number, r: number, offset: number = 1.5): string { // Expand dimensions outward by the offset const W = w + 2 * offset; const H = h + 2 * offset; @@ -122,6 +129,4 @@ export class BackToTop extends Component { `Z` // close path back to start ].join(' '); } - - } diff --git a/resources/views/layouts/parts/back-to-top.blade.php b/resources/views/layouts/parts/back-to-top.blade.php index 741859d1c35..b6efc97497b 100644 --- a/resources/views/layouts/parts/back-to-top.blade.php +++ b/resources/views/layouts/parts/back-to-top.blade.php @@ -12,4 +12,4 @@ @icon('chevron-up') {{ trans('common.back_to_top') }} - \ No newline at end of file + From 26a3cb8b7e36a421c4491ad987e053d96ee84075 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 6 Aug 2026 15:32:17 +0100 Subject: [PATCH 297/398] Views: Started implementation of new sidebar back-end --- .../Providers/ViewTweaksServiceProvider.php | 2 + app/Entities/Controllers/BookController.php | 2 - app/View/SidebarSection.php | 16 ++++++ app/View/SidebarSectionInterface.php | 20 ++++++++ app/View/SidebarSectionManager.php | 51 +++++++++++++++++++ .../SidebarSections/BooksIndexRecents.php | 26 ++++++++++ resources/views/books/index.blade.php | 4 +- 7 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 app/View/SidebarSection.php create mode 100644 app/View/SidebarSectionInterface.php create mode 100644 app/View/SidebarSectionManager.php create mode 100644 app/View/SidebarSections/BooksIndexRecents.php diff --git a/app/App/Providers/ViewTweaksServiceProvider.php b/app/App/Providers/ViewTweaksServiceProvider.php index 6771e513fa6..d2d45abcc5d 100644 --- a/app/App/Providers/ViewTweaksServiceProvider.php +++ b/app/App/Providers/ViewTweaksServiceProvider.php @@ -4,6 +4,7 @@ use BookStack\Entities\BreadcrumbsViewComposer; use BookStack\Util\DateFormatter; +use BookStack\View\SidebarSectionManager; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\View; @@ -33,6 +34,7 @@ public function boot(): void // View Globals View::share('dates', $this->app->make(DateFormatter::class)); + View::share('sidebar', $this->app->make(SidebarSectionManager::class)); // Custom blade view directives Blade::directive('icon', function ($expression) { diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index 98470d91ce8..9fa64d2934c 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -53,7 +53,6 @@ public function index(Request $request) $books = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); - $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false; $popular = $this->queries->popularForList()->take(4)->get(); $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); @@ -63,7 +62,6 @@ public function index(Request $request) return view('books.index', [ 'books' => $books, - 'recents' => $recents, 'popular' => $popular, 'new' => $new, 'view' => $view, diff --git a/app/View/SidebarSection.php b/app/View/SidebarSection.php new file mode 100644 index 00000000000..68e1aa29193 --- /dev/null +++ b/app/View/SidebarSection.php @@ -0,0 +1,16 @@ +view; + } +} diff --git a/app/View/SidebarSectionInterface.php b/app/View/SidebarSectionInterface.php new file mode 100644 index 00000000000..84032ab6174 --- /dev/null +++ b/app/View/SidebarSectionInterface.php @@ -0,0 +1,20 @@ + + */ + public function withData(array $viewData, Request $request): array; +} diff --git a/app/View/SidebarSectionManager.php b/app/View/SidebarSectionManager.php new file mode 100644 index 00000000000..82af3721ec4 --- /dev/null +++ b/app/View/SidebarSectionManager.php @@ -0,0 +1,51 @@ +[]>> + */ + protected array $sectionByLocation = [ + 'books-index' => [ + 'left' => [ + BooksIndexRecents::class, + ] + ], + ]; + + /** + * Register a sidebar section to be displayed at the given location and position. + * @param class-string $sectionClass + */ + public function register(string $location, string $position, string $sectionClass): void + { + if (!isset($this->sectionByLocation[$location])) { + $this->sectionByLocation[$location] = []; + } + + if (!isset($this->sectionByLocation[$location][$position])) { + $this->sectionByLocation[$location][$position] = []; + } + + $this->sectionByLocation[$location][$position][] = $sectionClass; + } + + /** + * Get all sidebar sections registered for a given location. + * + * @return SidebarSectionInterface[] + * @throws BindingResolutionException + */ + public function getSectionsForLocation(string $location, string $position): array + { + $sections = array_unique($this->sectionByLocation[$location][$position] ?? []); + return array_map(function (string $className) { + return app()->make($className); + }, $sections); + } +} diff --git a/app/View/SidebarSections/BooksIndexRecents.php b/app/View/SidebarSections/BooksIndexRecents.php new file mode 100644 index 00000000000..485e6d431fc --- /dev/null +++ b/app/View/SidebarSections/BooksIndexRecents.php @@ -0,0 +1,26 @@ +isGuest(); + $recents = $userSignedIn ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : null; + return [ + 'recents' => $recents, + ]; + } +} diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 660c008dfb1..60152410cb9 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -5,7 +5,9 @@ @stop @section('left') - @include('books.parts.index-sidebar-section-recents', ['recents' => $recents]) + @foreach($sidebar->getSectionsForLocation('books-index', 'left') as $section) + @include($section->getView(), $section->withData(get_defined_vars(), request())) + @endforeach @include('books.parts.index-sidebar-section-popular', ['popular' => $popular]) @include('books.parts.index-sidebar-section-new', ['new' => $new]) @stop From dead0bfb5fd5b725230a5020973ddd4fa9560a95 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 7 Aug 2026 08:32:27 +0100 Subject: [PATCH 298/398] Views: Extracted books index/show sidebar sections --- app/Entities/Controllers/BookController.php | 15 +-------- app/View/SidebarSectionManager.php | 28 +++++++++++++++- .../SidebarSections/BooksIndexActions.php | 18 +++++++++++ app/View/SidebarSections/BooksIndexNew.php | 29 +++++++++++++++++ .../SidebarSections/BooksIndexPopular.php | 24 ++++++++++++++ app/View/SidebarSections/BooksShowActions.php | 24 ++++++++++++++ .../SidebarSections/BooksShowActivity.php | 28 ++++++++++++++++ app/View/SidebarSections/BooksShowDetails.php | 32 +++++++++++++++++++ .../SidebarSections/BooksShowSearchForm.php | 18 +++++++++++ app/View/SidebarSections/BooksShowShelves.php | 23 +++++++++++++ app/View/SidebarSections/BooksShowTags.php | 18 +++++++++++ resources/views/books/index.blade.php | 8 ++--- .../show-sidebar-section-details.blade.php | 2 +- .../show-sidebar-section-shelves.blade.php | 4 +-- resources/views/books/show.blade.php | 13 +++----- .../views/common/sidebar-sections.blade.php | 11 +++++++ 16 files changed, 262 insertions(+), 33 deletions(-) create mode 100644 app/View/SidebarSections/BooksIndexActions.php create mode 100644 app/View/SidebarSections/BooksIndexNew.php create mode 100644 app/View/SidebarSections/BooksIndexPopular.php create mode 100644 app/View/SidebarSections/BooksShowActions.php create mode 100644 app/View/SidebarSections/BooksShowActivity.php create mode 100644 app/View/SidebarSections/BooksShowDetails.php create mode 100644 app/View/SidebarSections/BooksShowSearchForm.php create mode 100644 app/View/SidebarSections/BooksShowShelves.php create mode 100644 app/View/SidebarSections/BooksShowTags.php create mode 100644 resources/views/common/sidebar-sections.blade.php diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index 9fa64d2934c..aa4f99daa6d 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -2,10 +2,8 @@ namespace BookStack\Entities\Controllers; -use BookStack\Activity\ActivityQueries; use BookStack\Activity\ActivityType; use BookStack\Activity\Models\View; -use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookshelfQueries; use BookStack\Entities\Queries\EntityQueries; @@ -19,7 +17,6 @@ use BookStack\Facades\Activity; use BookStack\Http\Controller; use BookStack\Permissions\Permission; -use BookStack\References\ReferenceFetcher; use BookStack\Util\DatabaseTransaction; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; @@ -34,7 +31,6 @@ public function __construct( protected BookQueries $queries, protected EntityQueries $entityQueries, protected BookshelfQueries $shelfQueries, - protected ReferenceFetcher $referenceFetcher, ) { } @@ -53,8 +49,6 @@ public function index(Request $request) $books = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); - $popular = $this->queries->popularForList()->take(4)->get(); - $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); $this->shelfContext->clearShelfContext(); @@ -62,8 +56,6 @@ public function index(Request $request) return view('books.index', [ 'books' => $books, - 'popular' => $popular, - 'new' => $new, 'view' => $view, 'listOptions' => $listOptions, ]); @@ -125,7 +117,7 @@ public function store(Request $request, ?string $shelfSlug = null) /** * Display the specified book. */ - public function show(Request $request, ActivityQueries $activities, string $slug) + public function show(Request $request, string $slug) { try { $book = $this->queries->findVisibleBySlugOrFail($slug); @@ -138,7 +130,6 @@ public function show(Request $request, ActivityQueries $activities, string $slug } $bookChildren = (new BookContents($book))->getTree(true); - $bookParentShelves = $book->shelves()->scopes('visible')->get(); View::incrementFor($book); if ($request->has('shelf')) { @@ -151,10 +142,6 @@ public function show(Request $request, ActivityQueries $activities, string $slug 'book' => $book, 'current' => $book, 'bookChildren' => $bookChildren, - 'bookParentShelves' => $bookParentShelves, - 'watchOptions' => new UserEntityWatchOptions(user(), $book), - 'activity' => $activities->entityActivity($book, 20, 1), - 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book), ]); } diff --git a/app/View/SidebarSectionManager.php b/app/View/SidebarSectionManager.php index 82af3721ec4..d83b7afb0ea 100644 --- a/app/View/SidebarSectionManager.php +++ b/app/View/SidebarSectionManager.php @@ -2,7 +2,16 @@ namespace BookStack\View; +use BookStack\View\SidebarSections\BooksIndexActions; +use BookStack\View\SidebarSections\BooksIndexNew; +use BookStack\View\SidebarSections\BooksIndexPopular; use BookStack\View\SidebarSections\BooksIndexRecents; +use BookStack\View\SidebarSections\BooksShowActions; +use BookStack\View\SidebarSections\BooksShowActivity; +use BookStack\View\SidebarSections\BooksShowDetails; +use BookStack\View\SidebarSections\BooksShowSearchForm; +use BookStack\View\SidebarSections\BooksShowShelves; +use BookStack\View\SidebarSections\BooksShowTags; use Illuminate\Contracts\Container\BindingResolutionException; class SidebarSectionManager @@ -14,7 +23,24 @@ class SidebarSectionManager 'books-index' => [ 'left' => [ BooksIndexRecents::class, - ] + BooksIndexPopular::class, + BooksIndexNew::class, + ], + 'right' => [ + BooksIndexActions::class, + ], + ], + 'books-show' => [ + 'left' => [ + BooksShowSearchForm::class, + BooksShowTags::class, + BooksShowShelves::class, + BooksShowActivity::class, + ], + 'right' => [ + BooksShowDetails::class, + BooksShowActions::class, + ], ], ]; diff --git a/app/View/SidebarSections/BooksIndexActions.php b/app/View/SidebarSections/BooksIndexActions.php new file mode 100644 index 00000000000..62f856a651b --- /dev/null +++ b/app/View/SidebarSections/BooksIndexActions.php @@ -0,0 +1,18 @@ + $viewData['view'], + ]; + } +} diff --git a/app/View/SidebarSections/BooksIndexNew.php b/app/View/SidebarSections/BooksIndexNew.php new file mode 100644 index 00000000000..33a57f9d996 --- /dev/null +++ b/app/View/SidebarSections/BooksIndexNew.php @@ -0,0 +1,29 @@ +queries->visibleForList() + ->orderBy('created_at', 'desc') + ->take(4) + ->get(); + + return [ + 'new' => $new, + ]; + } +} diff --git a/app/View/SidebarSections/BooksIndexPopular.php b/app/View/SidebarSections/BooksIndexPopular.php new file mode 100644 index 00000000000..9f5cc613f91 --- /dev/null +++ b/app/View/SidebarSections/BooksIndexPopular.php @@ -0,0 +1,24 @@ + $this->queries->popularForList()->take(4)->get(), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowActions.php b/app/View/SidebarSections/BooksShowActions.php new file mode 100644 index 00000000000..0f97e625c6a --- /dev/null +++ b/app/View/SidebarSections/BooksShowActions.php @@ -0,0 +1,24 @@ + $book, + 'watchOptions' => new UserEntityWatchOptions(user(), $book), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowActivity.php b/app/View/SidebarSections/BooksShowActivity.php new file mode 100644 index 00000000000..d892c5ea730 --- /dev/null +++ b/app/View/SidebarSections/BooksShowActivity.php @@ -0,0 +1,28 @@ + $this->activityQueries->entityActivity($book, 20, 1), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowDetails.php b/app/View/SidebarSections/BooksShowDetails.php new file mode 100644 index 00000000000..bd91663b229 --- /dev/null +++ b/app/View/SidebarSections/BooksShowDetails.php @@ -0,0 +1,32 @@ +referenceFetcher->getReferenceCountToEntity($book); + + return [ + 'book' => $book, + 'watchOptions' => new UserEntityWatchOptions(user(), $book), + 'referenceCount' => $referenceCount, + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowSearchForm.php b/app/View/SidebarSections/BooksShowSearchForm.php new file mode 100644 index 00000000000..8fa24134b15 --- /dev/null +++ b/app/View/SidebarSections/BooksShowSearchForm.php @@ -0,0 +1,18 @@ + trans('entities.books_search_this'), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowShelves.php b/app/View/SidebarSections/BooksShowShelves.php new file mode 100644 index 00000000000..28d2d8aa9f0 --- /dev/null +++ b/app/View/SidebarSections/BooksShowShelves.php @@ -0,0 +1,23 @@ +shelves()->scopes('visible')->get(); + + return [ + 'shelves' => $shelves, + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowTags.php b/app/View/SidebarSections/BooksShowTags.php new file mode 100644 index 00000000000..351686c7f8a --- /dev/null +++ b/app/View/SidebarSections/BooksShowTags.php @@ -0,0 +1,18 @@ + $viewData['book'], + ]; + } +} diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 60152410cb9..020cb096638 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -5,13 +5,9 @@ @stop @section('left') - @foreach($sidebar->getSectionsForLocation('books-index', 'left') as $section) - @include($section->getView(), $section->withData(get_defined_vars(), request())) - @endforeach - @include('books.parts.index-sidebar-section-popular', ['popular' => $popular]) - @include('books.parts.index-sidebar-section-new', ['new' => $new]) + @include('common.sidebar-sections', ['location' => 'books-index', 'position' => 'left']) @stop @section('right') - @include('books.parts.index-sidebar-section-actions', ['view' => $view]) + @include('common.sidebar-sections', ['location' => 'books-index', 'position' => 'right']) @stop diff --git a/resources/views/books/parts/show-sidebar-section-details.blade.php b/resources/views/books/parts/show-sidebar-section-details.blade.php index 709d0ffd9a1..2c3d6d141dd 100644 --- a/resources/views/books/parts/show-sidebar-section-details.blade.php +++ b/resources/views/books/parts/show-sidebar-section-details.blade.php @@ -1,7 +1,7 @@
    {{ trans('common.details') }}