]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'docker-simplify' into development
authorDan Brown <redacted>
Sun, 1 Dec 2024 16:10:22 +0000 (16:10 +0000)
committerDan Brown <redacted>
Sun, 1 Dec 2024 16:10:22 +0000 (16:10 +0000)
201 files changed:
.github/translators.txt
app/Access/Oidc/OidcUserinfoResponse.php
app/Activity/ActivityType.php
app/Console/Commands/UpdateUrlCommand.php
app/Entities/Models/Chapter.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/Cloner.php
app/Entities/Tools/PageIncludeParser.php
app/Exceptions/ZipExportException.php [new file with mode: 0644]
app/Exceptions/ZipImportException.php [new file with mode: 0644]
app/Exceptions/ZipValidationException.php [new file with mode: 0644]
app/Exports/Controllers/BookExportApiController.php [moved from app/Entities/Controllers/BookExportApiController.php with 95% similarity]
app/Exports/Controllers/BookExportController.php [moved from app/Entities/Controllers/BookExportController.php with 74% similarity]
app/Exports/Controllers/ChapterExportApiController.php [moved from app/Entities/Controllers/ChapterExportApiController.php with 95% similarity]
app/Exports/Controllers/ChapterExportController.php [moved from app/Entities/Controllers/ChapterExportController.php with 78% similarity]
app/Exports/Controllers/ImportController.php [new file with mode: 0644]
app/Exports/Controllers/PageExportApiController.php [moved from app/Entities/Controllers/PageExportApiController.php with 95% similarity]
app/Exports/Controllers/PageExportController.php [moved from app/Entities/Controllers/PageExportController.php with 80% similarity]
app/Exports/ExportFormatter.php [moved from app/Entities/Tools/ExportFormatter.php with 98% similarity]
app/Exports/Import.php [new file with mode: 0644]
app/Exports/ImportRepo.php [new file with mode: 0644]
app/Exports/PdfGenerator.php [moved from app/Entities/Tools/PdfGenerator.php with 99% similarity]
app/Exports/ZipExports/Models/ZipExportAttachment.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportBook.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportChapter.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportImage.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportModel.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportPage.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportTag.php [new file with mode: 0644]
app/Exports/ZipExports/ZipExportBuilder.php [new file with mode: 0644]
app/Exports/ZipExports/ZipExportFiles.php [new file with mode: 0644]
app/Exports/ZipExports/ZipExportReader.php [new file with mode: 0644]
app/Exports/ZipExports/ZipExportReferences.php [new file with mode: 0644]
app/Exports/ZipExports/ZipExportValidator.php [new file with mode: 0644]
app/Exports/ZipExports/ZipFileReferenceRule.php [new file with mode: 0644]
app/Exports/ZipExports/ZipImportReferences.php [new file with mode: 0644]
app/Exports/ZipExports/ZipImportRunner.php [new file with mode: 0644]
app/Exports/ZipExports/ZipReferenceParser.php [new file with mode: 0644]
app/Exports/ZipExports/ZipUniqueIdRule.php [new file with mode: 0644]
app/Exports/ZipExports/ZipValidationHelper.php [new file with mode: 0644]
app/Http/Controller.php
app/Http/RangeSupportedStream.php
app/References/ModelResolvers/AttachmentModelResolver.php [new file with mode: 0644]
app/References/ModelResolvers/ImageModelResolver.php [new file with mode: 0644]
app/Translation/LocaleManager.php
app/Uploads/AttachmentService.php
app/Uploads/FileStorage.php [new file with mode: 0644]
app/Uploads/ImageService.php
app/Uploads/ImageStorage.php
app/Uploads/ImageStorageDisk.php
app/Util/HtmlDocument.php
bookstack-system-cli
composer.json
composer.lock
database/factories/Exports/ImportFactory.php [new file with mode: 0644]
database/migrations/2021_08_28_161743_add_export_role_permission.php
database/migrations/2024_10_29_114420_add_import_role_permission.php [new file with mode: 0644]
database/migrations/2024_11_02_160700_create_imports_table.php [new file with mode: 0644]
database/migrations/2024_11_27_171039_add_instance_id_setting.php [new file with mode: 0644]
dev/build/esbuild.js
dev/docs/portable-zip-file-format.md [new file with mode: 0644]
dev/licensing/php-library-licenses.txt
lang/ar/auth.php
lang/cs/activities.php
lang/cs/common.php
lang/cs/entities.php
lang/cs/errors.php
lang/da/activities.php
lang/da/settings.php
lang/en/activities.php
lang/en/entities.php
lang/en/errors.php
lang/en/settings.php
lang/en/validation.php
lang/et/common.php
lang/et/entities.php
lang/et/errors.php
lang/fr/common.php
lang/fr/entities.php
lang/fr/errors.php
lang/he/activities.php
lang/he/auth.php
lang/he/common.php
lang/he/components.php
lang/he/passwords.php
lang/he/preferences.php
lang/he/settings.php
lang/it/activities.php
lang/it/auth.php
lang/it/common.php
lang/it/components.php
lang/it/editor.php
lang/it/entities.php
lang/it/errors.php
lang/ja/common.php
lang/ja/entities.php
lang/ja/errors.php
lang/ka/activities.php
lang/ko/activities.php
lang/nl/common.php
lang/nl/entities.php
lang/nl/errors.php
lang/nl/settings.php
lang/nn/activities.php
lang/nn/auth.php
lang/pl/auth.php
lang/pl/common.php
lang/pl/entities.php
lang/pl/errors.php
lang/pt_BR/editor.php
lang/pt_BR/entities.php
lang/tk/activities.php [new file with mode: 0644]
lang/tk/auth.php [new file with mode: 0644]
lang/tk/common.php [new file with mode: 0644]
lang/tk/components.php [new file with mode: 0644]
lang/tk/editor.php [new file with mode: 0644]
lang/tk/entities.php [new file with mode: 0644]
lang/tk/errors.php [new file with mode: 0644]
lang/tk/notifications.php [new file with mode: 0644]
lang/tk/pagination.php [new file with mode: 0644]
lang/tk/passwords.php [new file with mode: 0644]
lang/tk/preferences.php [new file with mode: 0644]
lang/tk/settings.php [new file with mode: 0644]
lang/tk/validation.php [new file with mode: 0644]
lang/uk/common.php
lang/uk/entities.php
lang/uk/errors.php
lang/vi/activities.php
lang/vi/entities.php
lang/zh_TW/validation.php
resources/js/app.js [deleted file]
resources/js/app.ts [new file with mode: 0644]
resources/js/components/add-remove-rows.js
resources/js/components/ajax-delete-row.js
resources/js/components/ajax-form.js
resources/js/components/attachments.js
resources/js/components/auto-suggest.js
resources/js/components/book-sort.js
resources/js/components/chapter-contents.js
resources/js/components/code-editor.js
resources/js/components/collapsible.js
resources/js/components/confirm-dialog.js
resources/js/components/dropdown-search.js
resources/js/components/dropdown.js
resources/js/components/dropzone.js
resources/js/components/entity-permissions.js
resources/js/components/entity-search.js
resources/js/components/entity-selector.js
resources/js/components/event-emit-select.js
resources/js/components/expand-toggle.js
resources/js/components/global-search.js
resources/js/components/image-manager.js
resources/js/components/index.ts [moved from resources/js/components/index.js with 98% similarity]
resources/js/components/loading-button.ts [new file with mode: 0644]
resources/js/components/optional-input.js
resources/js/components/page-comment.js
resources/js/components/page-comments.js
resources/js/components/page-display.js
resources/js/components/page-editor.js
resources/js/components/pointer.js
resources/js/components/popup.js
resources/js/components/template-manager.js
resources/js/components/user-select.js
resources/js/global.d.ts
resources/js/markdown/codemirror.js
resources/js/services/animations.ts [moved from resources/js/services/animations.js with 63% similarity]
resources/js/services/dom.ts [moved from resources/js/services/dom.js with 63% similarity]
resources/js/services/keyboard-navigation.ts [moved from resources/js/services/keyboard-navigation.js with 66% similarity]
resources/js/services/util.js [deleted file]
resources/js/services/util.ts [new file with mode: 0644]
resources/js/wysiwyg-tinymce/plugin-drawio.js
resources/sass/_codemirror.scss
resources/sass/_forms.scss
resources/sass/styles.scss
resources/views/books/index.blade.php
resources/views/entities/export-menu.blade.php
resources/views/entities/selector.blade.php
resources/views/exports/import-show.blade.php [new file with mode: 0644]
resources/views/exports/import.blade.php [new file with mode: 0644]
resources/views/exports/parts/import-item.blade.php [new file with mode: 0644]
resources/views/exports/parts/import.blade.php [new file with mode: 0644]
resources/views/form/errors.blade.php
resources/views/settings/roles/parts/form.blade.php
routes/api.php
routes/web.php
tests/Auth/OidcTest.php
tests/Commands/UpdateUrlCommandTest.php
tests/Entity/EntitySearchTest.php
tests/Entity/ExportTest.php [deleted file]
tests/Exports/ExportUiTest.php [new file with mode: 0644]
tests/Exports/HtmlExportTest.php [new file with mode: 0644]
tests/Exports/MarkdownExportTest.php [new file with mode: 0644]
tests/Exports/PdfExportTest.php [new file with mode: 0644]
tests/Exports/TextExportTest.php [new file with mode: 0644]
tests/Exports/ZipExportTest.php [new file with mode: 0644]
tests/Exports/ZipExportValidatorTest.php [new file with mode: 0644]
tests/Exports/ZipImportRunnerTest.php [new file with mode: 0644]
tests/Exports/ZipImportTest.php [new file with mode: 0644]
tests/Exports/ZipResultData.php [new file with mode: 0644]
tests/Exports/ZipTestHelper.php [new file with mode: 0644]
tests/Uploads/AttachmentTest.php

index d0ab518ebdc2c5d2358a654a454de875624458b4..9699be70f8bc382a2676209b560d415b66c13089 100644 (file)
@@ -449,3 +449,9 @@ Avishay Rapp (AvishayRapp) :: Hebrew
 matthias4217 :: French
 Berke BOYLU2 (berkeboylu2) :: Turkish
 etwas7B :: German
+Mohammed srhiri (m.sghiri20) :: Arabic
+YongMin Kim (kym0118) :: Korean
+Rivo Zängov (Eraser) :: Estonian
+Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian
+ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional
+madnjpn (madnjpn.) :: Georgian
index 9aded654e31b7ee7ce754556e5272f0f7b9aaf52..33b8ec80665523a88a63ef9f3438e04546ad6d64 100644 (file)
@@ -11,7 +11,9 @@ class OidcUserinfoResponse implements ProvidesClaims
 
     public function __construct(ResponseInterface $response, string $issuer, array $keys)
     {
-        $contentType = $response->getHeader('Content-Type')[0];
+        $contentTypeHeaderValue = $response->getHeader('Content-Type')[0] ?? '';
+        $contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0]));
+
         if ($contentType === 'application/json') {
             $this->claims = json_decode($response->getBody()->getContents(), true);
         }
index 09b2ae73c561e9b8be68292f370d13afa5dd4de9..5ec9b9cf0dc7f452040b2ca3278c24d0ed0c9855 100644 (file)
@@ -67,6 +67,10 @@ class ActivityType
     const WEBHOOK_UPDATE = 'webhook_update';
     const WEBHOOK_DELETE = 'webhook_delete';
 
+    const IMPORT_CREATE = 'import_create';
+    const IMPORT_RUN = 'import_run';
+    const IMPORT_DELETE = 'import_delete';
+
     /**
      * Get all the possible values.
      */
index 0c95b0a3c8a0e66e5cebf5a37969266ab37774b5..e155878d331b8a5f3661603dfa1994c4007e9e51 100644 (file)
@@ -49,6 +49,7 @@ class UpdateUrlCommand extends Command
             'chapters'    => ['description_html'],
             'books'       => ['description_html'],
             'bookshelves' => ['description_html'],
+            'page_revisions' => ['html', 'text', 'markdown'],
             'images'      => ['url'],
             'settings'    => ['value'],
             'comments'    => ['html', 'text'],
@@ -77,6 +78,12 @@ class UpdateUrlCommand extends Command
         $this->info('URL update procedure complete.');
         $this->info('============================================================================');
         $this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.');
+
+        if (!str_starts_with($newUrl, url('/'))) {
+            $this->warn('You still need to update your APP_URL env value. This is currently set to:');
+            $this->warn(url('/'));
+        }
+
         $this->info('============================================================================');
 
         return 0;
index c926aaa647a7d75501b55880e1cd2c834990f8fc..088d199da675286af90ea73c3f24ca396ede2347 100644 (file)
@@ -60,6 +60,7 @@ class Chapter extends BookChild
 
     /**
      * Get the visible pages in this chapter.
+     * @returns Collection<Page>
      */
     public function getVisiblePages(): Collection
     {
index 1bc15392cec7b4478372b761c4179e3fa364f297..68b1c398f801d22ac3d74211f2e10714505083f4 100644 (file)
@@ -87,6 +87,17 @@ class PageRepo
         return $draft;
     }
 
+    /**
+     * Directly update the content for the given page from the provided input.
+     * Used for direct content access in a way that performs required changes
+     * (Search index & reference regen) without performing an official update.
+     */
+    public function setContentFromInput(Page $page, array $input): void
+    {
+        $this->updateTemplateStatusAndContentFromInput($page, $input);
+        $this->baseRepo->update($page, []);
+    }
+
     /**
      * Update a page in the system.
      */
@@ -121,7 +132,7 @@ class PageRepo
         return $page;
     }
 
-    protected function updateTemplateStatusAndContentFromInput(Page $page, array $input)
+    protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void
     {
         if (isset($input['template']) && userCan('templates-manage')) {
             $page->template = ($input['template'] === 'true');
index 2030b050c4b19f39e01b1c6312c3e381405ff855..2be6083e3ddcb810871ab42ad973070d1f41fd8e 100644 (file)
@@ -18,17 +18,12 @@ use Illuminate\Http\UploadedFile;
 
 class Cloner
 {
-    protected PageRepo $pageRepo;
-    protected ChapterRepo $chapterRepo;
-    protected BookRepo $bookRepo;
-    protected ImageService $imageService;
-
-    public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
-    {
-        $this->pageRepo = $pageRepo;
-        $this->chapterRepo = $chapterRepo;
-        $this->bookRepo = $bookRepo;
-        $this->imageService = $imageService;
+    public function __construct(
+        protected PageRepo $pageRepo,
+        protected ChapterRepo $chapterRepo,
+        protected BookRepo $bookRepo,
+        protected ImageService $imageService,
+    ) {
     }
 
     /**
index dad7c29e60ee3392de7b6d56726fee5455074eb5..e0b89f158704298702122f9839898be6c9e6ffde 100644 (file)
@@ -104,10 +104,10 @@ class PageIncludeParser
 
             if ($currentOffset < $tagStartOffset) {
                 $previousText = substr($text, $currentOffset, $tagStartOffset - $currentOffset);
-                $textNode->parentNode->insertBefore(new DOMText($previousText), $textNode);
+                $textNode->parentNode->insertBefore($this->doc->createTextNode($previousText), $textNode);
             }
 
-            $node = $textNode->parentNode->insertBefore(new DOMText($tagOuterContent), $textNode);
+            $node = $textNode->parentNode->insertBefore($this->doc->createTextNode($tagOuterContent), $textNode);
             $includeTags[] = new PageIncludeTag($tagInnerContent, $node);
             $currentOffset = $tagStartOffset + strlen($tagOuterContent);
         }
diff --git a/app/Exceptions/ZipExportException.php b/app/Exceptions/ZipExportException.php
new file mode 100644 (file)
index 0000000..b2c811e
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+class ZipExportException extends \Exception
+{
+}
diff --git a/app/Exceptions/ZipImportException.php b/app/Exceptions/ZipImportException.php
new file mode 100644 (file)
index 0000000..452365c
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+class ZipImportException extends \Exception
+{
+    public function __construct(
+        public array $errors
+    ) {
+        $message = "Import failed with errors:" . implode("\n", $this->errors);
+        parent::__construct($message);
+    }
+}
diff --git a/app/Exceptions/ZipValidationException.php b/app/Exceptions/ZipValidationException.php
new file mode 100644 (file)
index 0000000..aaaee79
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+class ZipValidationException extends \Exception
+{
+    public function __construct(
+        public array $errors
+    ) {
+        parent::__construct();
+    }
+}
similarity index 95%
rename from app/Entities/Controllers/BookExportApiController.php
rename to app/Exports/Controllers/BookExportApiController.php
index 1161ddb8886964423f088630424317225914eb74..164946b0c781d30d472bcda8cfb4066e9b71763d 100644 (file)
@@ -1,9 +1,9 @@
 <?php
 
-namespace BookStack\Entities\Controllers;
+namespace BookStack\Exports\Controllers;
 
 use BookStack\Entities\Queries\BookQueries;
-use BookStack\Entities\Tools\ExportFormatter;
+use BookStack\Exports\ExportFormatter;
 use BookStack\Http\ApiController;
 use Throwable;
 
similarity index 74%
rename from app/Entities/Controllers/BookExportController.php
rename to app/Exports/Controllers/BookExportController.php
index 5c1a964c1e1037c2d60bd789cb5a049acb71b1c2..f726175a086acab697ce6e05575f69f90b58be8f 100644 (file)
@@ -1,9 +1,11 @@
 <?php
 
-namespace BookStack\Entities\Controllers;
+namespace BookStack\Exports\Controllers;
 
 use BookStack\Entities\Queries\BookQueries;
-use BookStack\Entities\Tools\ExportFormatter;
+use BookStack\Exceptions\NotFoundException;
+use BookStack\Exports\ExportFormatter;
+use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\Controller;
 use Throwable;
 
@@ -63,4 +65,16 @@ class BookExportController extends Controller
 
         return $this->download()->directly($textContent, $bookSlug . '.md');
     }
+
+    /**
+     * Export a book to a contained ZIP export file.
+     * @throws NotFoundException
+     */
+    public function zip(string $bookSlug, ZipExportBuilder $builder)
+    {
+        $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
+        $zip = $builder->buildForBook($book);
+
+        return $this->download()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip));
+    }
 }
similarity index 95%
rename from app/Entities/Controllers/ChapterExportApiController.php
rename to app/Exports/Controllers/ChapterExportApiController.php
index ceb2522b2118212657a9a1bbab666fe42c28581c..9914e2b7fbed242405e2bd41e0555674eb9f5ca0 100644 (file)
@@ -1,9 +1,9 @@
 <?php
 
-namespace BookStack\Entities\Controllers;
+namespace BookStack\Exports\Controllers;
 
 use BookStack\Entities\Queries\ChapterQueries;
-use BookStack\Entities\Tools\ExportFormatter;
+use BookStack\Exports\ExportFormatter;
 use BookStack\Http\ApiController;
 use Throwable;
 
similarity index 78%
rename from app/Entities/Controllers/ChapterExportController.php
rename to app/Exports/Controllers/ChapterExportController.php
index ead601ab46b432ca541c74df4d77a15f7cd8aeda..0d7a5c0d195ec94181cbacca251a632d2008e7c0 100644 (file)
@@ -1,10 +1,11 @@
 <?php
 
-namespace BookStack\Entities\Controllers;
+namespace BookStack\Exports\Controllers;
 
 use BookStack\Entities\Queries\ChapterQueries;
-use BookStack\Entities\Tools\ExportFormatter;
 use BookStack\Exceptions\NotFoundException;
+use BookStack\Exports\ExportFormatter;
+use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\Controller;
 use Throwable;
 
@@ -70,4 +71,16 @@ class ChapterExportController extends Controller
 
         return $this->download()->directly($chapterText, $chapterSlug . '.md');
     }
+
+    /**
+     * Export a book to a contained ZIP export file.
+     * @throws NotFoundException
+     */
+    public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $builder)
+    {
+        $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
+        $zip = $builder->buildForChapter($chapter);
+
+        return $this->download()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip));
+    }
 }
diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php
new file mode 100644 (file)
index 0000000..b938dac
--- /dev/null
@@ -0,0 +1,110 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BookStack\Exports\Controllers;
+
+use BookStack\Exceptions\ZipImportException;
+use BookStack\Exceptions\ZipValidationException;
+use BookStack\Exports\ImportRepo;
+use BookStack\Http\Controller;
+use BookStack\Uploads\AttachmentService;
+use Illuminate\Http\Request;
+
+class ImportController extends Controller
+{
+    public function __construct(
+        protected ImportRepo $imports,
+    ) {
+        $this->middleware('can:content-import');
+    }
+
+    /**
+     * Show the view to start a new import, and also list out the existing
+     * in progress imports that are visible to the user.
+     */
+    public function start()
+    {
+        $imports = $this->imports->getVisibleImports();
+
+        $this->setPageTitle(trans('entities.import'));
+
+        return view('exports.import', [
+            'imports' => $imports,
+            'zipErrors' => session()->pull('validation_errors') ?? [],
+        ]);
+    }
+
+    /**
+     * Upload, validate and store an import file.
+     */
+    public function upload(Request $request)
+    {
+        $this->validate($request, [
+            'file' => ['required', ...AttachmentService::getFileValidationRules()]
+        ]);
+
+        $file = $request->file('file');
+        try {
+            $import = $this->imports->storeFromUpload($file);
+        } catch (ZipValidationException $exception) {
+            return redirect('/import')->with('validation_errors', $exception->errors);
+        }
+
+        return redirect($import->getUrl());
+    }
+
+    /**
+     * Show a pending import, with a form to allow progressing
+     * with the import process.
+     */
+    public function show(int $id)
+    {
+        $import = $this->imports->findVisible($id);
+
+        $this->setPageTitle(trans('entities.import_continue'));
+
+        return view('exports.import-show', [
+            'import' => $import,
+            'data' => $import->decodeMetadata(),
+        ]);
+    }
+
+    /**
+     * Run the import process against an uploaded import ZIP.
+     */
+    public function run(int $id, Request $request)
+    {
+        $import = $this->imports->findVisible($id);
+        $parent = null;
+
+        if ($import->type === 'page' || $import->type === 'chapter') {
+            session()->setPreviousUrl($import->getUrl());
+            $data = $this->validate($request, [
+                'parent' => ['required', 'string'],
+            ]);
+            $parent = $data['parent'];
+        }
+
+        try {
+            $entity = $this->imports->runImport($import, $parent);
+        } catch (ZipImportException $exception) {
+            session()->flush();
+            $this->showErrorNotification(trans('errors.import_zip_failed_notification'));
+            return redirect($import->getUrl())->with('import_errors', $exception->errors);
+        }
+
+        return redirect($entity->getUrl());
+    }
+
+    /**
+     * Delete an active pending import from the filesystem and database.
+     */
+    public function delete(int $id)
+    {
+        $import = $this->imports->findVisible($id);
+        $this->imports->deleteImport($import);
+
+        return redirect('/import');
+    }
+}
similarity index 95%
rename from app/Entities/Controllers/PageExportApiController.php
rename to app/Exports/Controllers/PageExportApiController.php
index 693760bc8e727ddb6ce13a8c004f3a808f816670..c6e20b615d2426f92af9afbd03d2cceac492b0d2 100644 (file)
@@ -1,9 +1,9 @@
 <?php
 
-namespace BookStack\Entities\Controllers;
+namespace BookStack\Exports\Controllers;
 
 use BookStack\Entities\Queries\PageQueries;
-use BookStack\Entities\Tools\ExportFormatter;
+use BookStack\Exports\ExportFormatter;
 use BookStack\Http\ApiController;
 use Throwable;
 
similarity index 80%
rename from app/Entities/Controllers/PageExportController.php
rename to app/Exports/Controllers/PageExportController.php
index be97f1930bdd28e61f5f14d98647b336cc1439cf..34e67ffcf7075ede6b30a2842789ef8fa204c7f0 100644 (file)
@@ -1,11 +1,12 @@
 <?php
 
-namespace BookStack\Entities\Controllers;
+namespace BookStack\Exports\Controllers;
 
 use BookStack\Entities\Queries\PageQueries;
-use BookStack\Entities\Tools\ExportFormatter;
 use BookStack\Entities\Tools\PageContent;
 use BookStack\Exceptions\NotFoundException;
+use BookStack\Exports\ExportFormatter;
+use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\Controller;
 use Throwable;
 
@@ -74,4 +75,16 @@ class PageExportController extends Controller
 
         return $this->download()->directly($pageText, $pageSlug . '.md');
     }
+
+    /**
+     * Export a page to a contained ZIP export file.
+     * @throws NotFoundException
+     */
+    public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builder)
+    {
+        $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
+        $zip = $builder->buildForPage($page);
+
+        return $this->download()->streamedDirectly(fopen($zip, 'r'), $pageSlug . '.zip', filesize($zip));
+    }
 }
similarity index 98%
rename from app/Entities/Tools/ExportFormatter.php
rename to app/Exports/ExportFormatter.php
index beddfe8e6e0f08cb6e0f373a6d46d202a9b6056e..4f78830b075cb49f053d88672610ff365684abed 100644 (file)
@@ -1,11 +1,13 @@
 <?php
 
-namespace BookStack\Entities\Tools;
+namespace BookStack\Exports;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
+use BookStack\Entities\Tools\PageContent;
 use BookStack\Uploads\ImageService;
 use BookStack\Util\CspService;
 use BookStack\Util\HtmlDocument;
diff --git a/app/Exports/Import.php b/app/Exports/Import.php
new file mode 100644 (file)
index 0000000..9c1771c
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+namespace BookStack\Exports;
+
+use BookStack\Activity\Models\Loggable;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use BookStack\Users\Models\User;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int $id
+ * @property string $path
+ * @property string $name
+ * @property int $size - ZIP size in bytes
+ * @property string $type
+ * @property string $metadata
+ * @property int $created_by
+ * @property Carbon $created_at
+ * @property Carbon $updated_at
+ * @property User $createdBy
+ */
+class Import extends Model implements Loggable
+{
+    use HasFactory;
+
+    public function getSizeString(): string
+    {
+        $mb = round($this->size / 1000000, 2);
+        return "{$mb} MB";
+    }
+
+    /**
+     * Get the URL to view/continue this import.
+     */
+    public function getUrl(string $path = ''): string
+    {
+        $path = ltrim($path, '/');
+        return url("/import/{$this->id}" . ($path ? '/' . $path : ''));
+    }
+
+    public function logDescriptor(): string
+    {
+        return "({$this->id}) {$this->name}";
+    }
+
+    public function createdBy(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+
+    public function decodeMetadata(): ZipExportBook|ZipExportChapter|ZipExportPage|null
+    {
+        $metadataArray = json_decode($this->metadata, true);
+        return match ($this->type) {
+            'book' => ZipExportBook::fromArray($metadataArray),
+            'chapter' => ZipExportChapter::fromArray($metadataArray),
+            'page' => ZipExportPage::fromArray($metadataArray),
+            default => null,
+        };
+    }
+}
diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php
new file mode 100644 (file)
index 0000000..f72386c
--- /dev/null
@@ -0,0 +1,137 @@
+<?php
+
+namespace BookStack\Exports;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Queries\EntityQueries;
+use BookStack\Exceptions\FileUploadException;
+use BookStack\Exceptions\ZipExportException;
+use BookStack\Exceptions\ZipImportException;
+use BookStack\Exceptions\ZipValidationException;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use BookStack\Exports\ZipExports\ZipExportReader;
+use BookStack\Exports\ZipExports\ZipExportValidator;
+use BookStack\Exports\ZipExports\ZipImportRunner;
+use BookStack\Facades\Activity;
+use BookStack\Uploads\FileStorage;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Facades\DB;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+
+class ImportRepo
+{
+    public function __construct(
+        protected FileStorage $storage,
+        protected ZipImportRunner $importer,
+        protected EntityQueries $entityQueries,
+    ) {
+    }
+
+    /**
+     * @return Collection<Import>
+     */
+    public function getVisibleImports(): Collection
+    {
+        $query = Import::query();
+
+        if (!userCan('settings-manage')) {
+            $query->where('created_by', user()->id);
+        }
+
+        return $query->get();
+    }
+
+    public function findVisible(int $id): Import
+    {
+        $query = Import::query();
+
+        if (!userCan('settings-manage')) {
+            $query->where('created_by', user()->id);
+        }
+
+        return $query->findOrFail($id);
+    }
+
+    /**
+     * @throws FileUploadException
+     * @throws ZipValidationException
+     * @throws ZipExportException
+     */
+    public function storeFromUpload(UploadedFile $file): Import
+    {
+        $zipPath = $file->getRealPath();
+        $reader = new ZipExportReader($zipPath);
+
+        $errors = (new ZipExportValidator($reader))->validate();
+        if ($errors) {
+            throw new ZipValidationException($errors);
+        }
+
+        $exportModel = $reader->decodeDataToExportModel();
+
+        $import = new Import();
+        $import->type = match (get_class($exportModel)) {
+            ZipExportPage::class => 'page',
+            ZipExportChapter::class => 'chapter',
+            ZipExportBook::class => 'book',
+        };
+
+        $import->name = $exportModel->name;
+        $import->created_by = user()->id;
+        $import->size = filesize($zipPath);
+
+        $exportModel->metadataOnly();
+        $import->metadata = json_encode($exportModel);
+
+        $path = $this->storage->uploadFile(
+            $file,
+            'uploads/files/imports/',
+            '',
+            'zip'
+        );
+
+        $import->path = $path;
+        $import->save();
+
+        Activity::add(ActivityType::IMPORT_CREATE, $import);
+
+        return $import;
+    }
+
+    /**
+     * @throws ZipImportException
+     */
+    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;
+        }
+
+        DB::beginTransaction();
+        try {
+            $model = $this->importer->run($import, $parentModel);
+        } catch (ZipImportException $e) {
+            DB::rollBack();
+            $this->importer->revertStoredFiles();
+            throw $e;
+        }
+
+        DB::commit();
+        $this->deleteImport($import);
+        Activity::add(ActivityType::IMPORT_RUN, $import);
+
+        return $model;
+    }
+
+    public function deleteImport(Import $import): void
+    {
+        $this->storage->delete($import->path);
+        $import->delete();
+
+        Activity::add(ActivityType::IMPORT_DELETE, $import);
+    }
+}
similarity index 99%
rename from app/Entities/Tools/PdfGenerator.php
rename to app/Exports/PdfGenerator.php
index 79cd1b02f7ff1daac18cbd30c6c1c3679ed0c29f..0524e063fb259ca22e8e9a6e3c9a22352bb6e2ac 100644 (file)
@@ -1,10 +1,10 @@
 <?php
 
-namespace BookStack\Entities\Tools;
+namespace BookStack\Exports;
 
 use BookStack\Exceptions\PdfExportException;
-use Knp\Snappy\Pdf as SnappyPdf;
 use Dompdf\Dompdf;
+use Knp\Snappy\Pdf as SnappyPdf;
 use Symfony\Component\Process\Exception\ProcessTimedOutException;
 use Symfony\Component\Process\Process;
 
diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php
new file mode 100644 (file)
index 0000000..4f5b2f2
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+namespace BookStack\Exports\ZipExports\Models;
+
+use BookStack\Exports\ZipExports\ZipExportFiles;
+use BookStack\Exports\ZipExports\ZipValidationHelper;
+use BookStack\Uploads\Attachment;
+
+class ZipExportAttachment extends ZipExportModel
+{
+    public ?int $id = null;
+    public string $name;
+    public ?string $link = null;
+    public ?string $file = null;
+
+    public function metadataOnly(): void
+    {
+        $this->link = $this->file = null;
+    }
+
+    public static function fromModel(Attachment $model, ZipExportFiles $files): self
+    {
+        $instance = new self();
+        $instance->id = $model->id;
+        $instance->name = $model->name;
+
+        if ($model->external) {
+            $instance->link = $model->path;
+        } else {
+            $instance->file = $files->referenceForAttachment($model);
+        }
+
+        return $instance;
+    }
+
+    public static function fromModelArray(array $attachmentArray, ZipExportFiles $files): array
+    {
+        return array_values(array_map(function (Attachment $attachment) use ($files) {
+            return self::fromModel($attachment, $files);
+        }, $attachmentArray));
+    }
+
+    public static function validate(ZipValidationHelper $context, array $data): array
+    {
+        $rules = [
+            'id'    => ['nullable', 'int', $context->uniqueIdRule('attachment')],
+            'name'  => ['required', 'string', 'min:1'],
+            'link'  => ['required_without:file', 'nullable', 'string'],
+            'file'  => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],
+        ];
+
+        return $context->validateData($data, $rules);
+    }
+
+    public static function fromArray(array $data): self
+    {
+        $model = new self();
+
+        $model->id = $data['id'] ?? null;
+        $model->name = $data['name'];
+        $model->link = $data['link'] ?? null;
+        $model->file = $data['file'] ?? null;
+
+        return $model;
+    }
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php
new file mode 100644 (file)
index 0000000..4f641d2
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+
+namespace BookStack\Exports\ZipExports\Models;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Exports\ZipExports\ZipExportFiles;
+use BookStack\Exports\ZipExports\ZipValidationHelper;
+
+class ZipExportBook extends ZipExportModel
+{
+    public ?int $id = null;
+    public string $name;
+    public ?string $description_html = null;
+    public ?string $cover = null;
+    /** @var ZipExportChapter[] */
+    public array $chapters = [];
+    /** @var ZipExportPage[] */
+    public array $pages = [];
+    /** @var ZipExportTag[] */
+    public array $tags = [];
+
+    public function metadataOnly(): void
+    {
+        $this->description_html = $this->cover = null;
+
+        foreach ($this->chapters as $chapter) {
+            $chapter->metadataOnly();
+        }
+        foreach ($this->pages as $page) {
+            $page->metadataOnly();
+        }
+        foreach ($this->tags as $tag) {
+            $tag->metadataOnly();
+        }
+    }
+
+    public function children(): array
+    {
+        $children = [
+            ...$this->pages,
+            ...$this->chapters,
+        ];
+
+        usort($children, function ($a, $b) {
+            return ($a->priority ?? 0) - ($b->priority ?? 0);
+        });
+
+        return $children;
+    }
+
+    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();
+
+        if ($model->cover) {
+            $instance->cover = $files->referenceForImage($model->cover);
+        }
+
+        $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
+
+        $chapters = [];
+        $pages = [];
+
+        $children = $model->getDirectVisibleChildren()->all();
+        foreach ($children as $child) {
+            if ($child instanceof Chapter) {
+                $chapters[] = $child;
+            } else if ($child instanceof Page) {
+                $pages[] = $child;
+            }
+        }
+
+        $instance->pages = ZipExportPage::fromModelArray($pages, $files);
+        $instance->chapters = ZipExportChapter::fromModelArray($chapters, $files);
+
+        return $instance;
+    }
+
+    public static function validate(ZipValidationHelper $context, array $data): array
+    {
+        $rules = [
+            'id'    => ['nullable', 'int', $context->uniqueIdRule('book')],
+            'name'  => ['required', 'string', 'min:1'],
+            'description_html' => ['nullable', 'string'],
+            'cover' => ['nullable', 'string', $context->fileReferenceRule()],
+            'tags' => ['array'],
+            'pages' => ['array'],
+            'chapters' => ['array'],
+        ];
+
+        $errors = $context->validateData($data, $rules);
+        $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
+        $errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class);
+        $errors['chapters'] = $context->validateRelations($data['chapters'] ?? [], ZipExportChapter::class);
+
+        return $errors;
+    }
+
+    public static function fromArray(array $data): self
+    {
+        $model = new self();
+
+        $model->id = $data['id'] ?? null;
+        $model->name = $data['name'];
+        $model->description_html = $data['description_html'] ?? null;
+        $model->cover = $data['cover'] ?? null;
+        $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
+        $model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []);
+        $model->chapters = ZipExportChapter::fromManyArray($data['chapters'] ?? []);
+
+        return $model;
+    }
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php
new file mode 100644 (file)
index 0000000..bf2dc78
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+
+namespace BookStack\Exports\ZipExports\Models;
+
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Exports\ZipExports\ZipExportFiles;
+use BookStack\Exports\ZipExports\ZipValidationHelper;
+
+class ZipExportChapter extends ZipExportModel
+{
+    public ?int $id = null;
+    public string $name;
+    public ?string $description_html = null;
+    public ?int $priority = null;
+    /** @var ZipExportPage[] */
+    public array $pages = [];
+    /** @var ZipExportTag[] */
+    public array $tags = [];
+
+    public function metadataOnly(): void
+    {
+        $this->description_html = null;
+
+        foreach ($this->pages as $page) {
+            $page->metadataOnly();
+        }
+        foreach ($this->tags as $tag) {
+            $tag->metadataOnly();
+        }
+    }
+
+    public function children(): array
+    {
+        return $this->pages;
+    }
+
+    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->priority = $model->priority;
+        $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
+
+        $pages = $model->getVisiblePages()->filter(fn (Page $page) => !$page->draft)->all();
+        $instance->pages = ZipExportPage::fromModelArray($pages, $files);
+
+        return $instance;
+    }
+
+    /**
+     * @param Chapter[] $chapterArray
+     * @return self[]
+     */
+    public static function fromModelArray(array $chapterArray, ZipExportFiles $files): array
+    {
+        return array_values(array_map(function (Chapter $chapter) use ($files) {
+            return self::fromModel($chapter, $files);
+        }, $chapterArray));
+    }
+
+    public static function validate(ZipValidationHelper $context, array $data): array
+    {
+        $rules = [
+            'id'    => ['nullable', 'int', $context->uniqueIdRule('chapter')],
+            'name'  => ['required', 'string', 'min:1'],
+            'description_html' => ['nullable', 'string'],
+            'priority' => ['nullable', 'int'],
+            'tags' => ['array'],
+            'pages' => ['array'],
+        ];
+
+        $errors = $context->validateData($data, $rules);
+        $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
+        $errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class);
+
+        return $errors;
+    }
+
+    public static function fromArray(array $data): self
+    {
+        $model = new self();
+
+        $model->id = $data['id'] ?? null;
+        $model->name = $data['name'];
+        $model->description_html = $data['description_html'] ?? null;
+        $model->priority = isset($data['priority']) ? intval($data['priority']) : null;
+        $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
+        $model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []);
+
+        return $model;
+    }
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php
new file mode 100644 (file)
index 0000000..e0e7d11
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+
+namespace BookStack\Exports\ZipExports\Models;
+
+use BookStack\Exports\ZipExports\ZipExportFiles;
+use BookStack\Exports\ZipExports\ZipValidationHelper;
+use BookStack\Uploads\Image;
+use Illuminate\Validation\Rule;
+
+class ZipExportImage extends ZipExportModel
+{
+    public ?int $id = null;
+    public string $name;
+    public string $file;
+    public string $type;
+
+    public static function fromModel(Image $model, ZipExportFiles $files): self
+    {
+        $instance = new self();
+        $instance->id = $model->id;
+        $instance->name = $model->name;
+        $instance->type = $model->type;
+        $instance->file = $files->referenceForImage($model);
+
+        return $instance;
+    }
+
+    public function metadataOnly(): void
+    {
+        //
+    }
+
+    public static function validate(ZipValidationHelper $context, array $data): array
+    {
+        $acceptedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
+        $rules = [
+            'id'    => ['nullable', 'int', $context->uniqueIdRule('image')],
+            'name'  => ['required', 'string', 'min:1'],
+            'file'  => ['required', 'string', $context->fileReferenceRule($acceptedImageTypes)],
+            'type'  => ['required', 'string', Rule::in(['gallery', 'drawio'])],
+        ];
+
+        return $context->validateData($data, $rules);
+    }
+
+    public static function fromArray(array $data): self
+    {
+        $model = new self();
+
+        $model->id = $data['id'] ?? null;
+        $model->name = $data['name'];
+        $model->file = $data['file'];
+        $model->type = $data['type'];
+
+        return $model;
+    }
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportModel.php b/app/Exports/ZipExports/Models/ZipExportModel.php
new file mode 100644 (file)
index 0000000..d3a8c35
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+
+namespace BookStack\Exports\ZipExports\Models;
+
+use BookStack\Exports\ZipExports\ZipValidationHelper;
+use JsonSerializable;
+
+abstract class ZipExportModel implements JsonSerializable
+{
+    /**
+     * Handle the serialization to JSON.
+     * For these exports, we filter out optional (represented as nullable) fields
+     * just to clean things up and prevent confusion to avoid null states in the
+     * resulting export format itself.
+     */
+    public function jsonSerialize(): array
+    {
+        $publicProps = get_object_vars(...)->__invoke($this);
+        return array_filter($publicProps, fn ($value) => $value !== null);
+    }
+
+    /**
+     * Validate the given array of data intended for this model.
+     * Return an array of validation errors messages.
+     * Child items can be considered in the validation result by returning a keyed
+     * item in the array for its own validation messages.
+     */
+    abstract public static function validate(ZipValidationHelper $context, array $data): array;
+
+    /**
+     * Decode the array of data into this export model.
+     */
+    abstract public static function fromArray(array $data): self;
+
+    /**
+     * Decode an array of array data into an array of export models.
+     * @param array[] $data
+     * @return self[]
+     */
+    public static function fromManyArray(array $data): array
+    {
+        $results = [];
+        foreach ($data as $item) {
+            $results[] = static::fromArray($item);
+        }
+        return $results;
+    }
+
+    /**
+     * Remove additional content in this model to reduce it down
+     * to just essential id/name values for identification.
+     *
+     * The result of this may be something that does not pass validation, but is
+     * simple for the purpose of creating a contents.
+     */
+    abstract public function metadataOnly(): void;
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php
new file mode 100644 (file)
index 0000000..097443d
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+
+namespace BookStack\Exports\ZipExports\Models;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Exports\ZipExports\ZipExportFiles;
+use BookStack\Exports\ZipExports\ZipValidationHelper;
+
+class ZipExportPage extends ZipExportModel
+{
+    public ?int $id = null;
+    public string $name;
+    public ?string $html = null;
+    public ?string $markdown = null;
+    public ?int $priority = null;
+    /** @var ZipExportAttachment[] */
+    public array $attachments = [];
+    /** @var ZipExportImage[] */
+    public array $images = [];
+    /** @var ZipExportTag[] */
+    public array $tags = [];
+
+    public function metadataOnly(): void
+    {
+        $this->html = $this->markdown = null;
+
+        foreach ($this->attachments as $attachment) {
+            $attachment->metadataOnly();
+        }
+        foreach ($this->images as $image) {
+            $image->metadataOnly();
+        }
+        foreach ($this->tags as $tag) {
+            $tag->metadataOnly();
+        }
+    }
+
+    public static function fromModel(Page $model, ZipExportFiles $files): self
+    {
+        $instance = new self();
+        $instance->id = $model->id;
+        $instance->name = $model->name;
+        $instance->html = (new PageContent($model))->render();
+        $instance->priority = $model->priority;
+
+        if (!empty($model->markdown)) {
+            $instance->markdown = $model->markdown;
+        }
+
+        $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
+        $instance->attachments = ZipExportAttachment::fromModelArray($model->attachments()->get()->all(), $files);
+
+        return $instance;
+    }
+
+    /**
+     * @param Page[] $pageArray
+     * @return self[]
+     */
+    public static function fromModelArray(array $pageArray, ZipExportFiles $files): array
+    {
+        return array_values(array_map(function (Page $page) use ($files) {
+            return self::fromModel($page, $files);
+        }, $pageArray));
+    }
+
+    public static function validate(ZipValidationHelper $context, array $data): array
+    {
+        $rules = [
+            'id'    => ['nullable', 'int', $context->uniqueIdRule('page')],
+            'name'  => ['required', 'string', 'min:1'],
+            'html' => ['nullable', 'string'],
+            'markdown' => ['nullable', 'string'],
+            'priority' => ['nullable', 'int'],
+            'attachments' => ['array'],
+            'images' => ['array'],
+            'tags' => ['array'],
+        ];
+
+        $errors = $context->validateData($data, $rules);
+        $errors['attachments'] = $context->validateRelations($data['attachments'] ?? [], ZipExportAttachment::class);
+        $errors['images'] = $context->validateRelations($data['images'] ?? [], ZipExportImage::class);
+        $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
+
+        return $errors;
+    }
+
+    public static function fromArray(array $data): self
+    {
+        $model = new self();
+
+        $model->id = $data['id'] ?? null;
+        $model->name = $data['name'];
+        $model->html = $data['html'] ?? null;
+        $model->markdown = $data['markdown'] ?? null;
+        $model->priority = isset($data['priority']) ? intval($data['priority']) : null;
+        $model->attachments = ZipExportAttachment::fromManyArray($data['attachments'] ?? []);
+        $model->images = ZipExportImage::fromManyArray($data['images'] ?? []);
+        $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
+
+        return $model;
+    }
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php
new file mode 100644 (file)
index 0000000..6b4720f
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+namespace BookStack\Exports\ZipExports\Models;
+
+use BookStack\Activity\Models\Tag;
+use BookStack\Exports\ZipExports\ZipValidationHelper;
+
+class ZipExportTag extends ZipExportModel
+{
+    public string $name;
+    public ?string $value = null;
+
+    public function metadataOnly(): void
+    {
+        $this->value =  null;
+    }
+
+    public static function fromModel(Tag $model): self
+    {
+        $instance = new self();
+        $instance->name = $model->name;
+        $instance->value = $model->value;
+
+        return $instance;
+    }
+
+    public static function fromModelArray(array $tagArray): array
+    {
+        return array_values(array_map(self::fromModel(...), $tagArray));
+    }
+
+    public static function validate(ZipValidationHelper $context, array $data): array
+    {
+        $rules = [
+            'name'  => ['required', 'string', 'min:1'],
+            'value' => ['nullable', 'string'],
+        ];
+
+        return $context->validateData($data, $rules);
+    }
+
+    public static function fromArray(array $data): self
+    {
+        $model = new self();
+
+        $model->name = $data['name'];
+        $model->value = $data['value'] ?? null;
+
+        return $model;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipExportBuilder.php b/app/Exports/ZipExports/ZipExportBuilder.php
new file mode 100644 (file)
index 0000000..4c5c638
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\ZipExportException;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use ZipArchive;
+
+class ZipExportBuilder
+{
+    protected array $data = [];
+
+    public function __construct(
+        protected ZipExportFiles $files,
+        protected ZipExportReferences $references,
+    ) {
+    }
+
+    /**
+     * @throws ZipExportException
+     */
+    public function buildForPage(Page $page): string
+    {
+        $exportPage = ZipExportPage::fromModel($page, $this->files);
+        $this->data['page'] = $exportPage;
+
+        $this->references->addPage($exportPage);
+
+        return $this->build();
+    }
+
+    /**
+     * @throws ZipExportException
+     */
+    public function buildForChapter(Chapter $chapter): string
+    {
+        $exportChapter = ZipExportChapter::fromModel($chapter, $this->files);
+        $this->data['chapter'] = $exportChapter;
+
+        $this->references->addChapter($exportChapter);
+
+        return $this->build();
+    }
+
+    /**
+     * @throws ZipExportException
+     */
+    public function buildForBook(Book $book): string
+    {
+        $exportBook = ZipExportBook::fromModel($book, $this->files);
+        $this->data['book'] = $exportBook;
+
+        $this->references->addBook($exportBook);
+
+        return $this->build();
+    }
+
+    /**
+     * @throws ZipExportException
+     */
+    protected function build(): string
+    {
+        $this->references->buildReferences($this->files);
+
+        $this->data['exported_at'] = date(DATE_ATOM);
+        $this->data['instance'] = [
+            'id'      => setting('instance-id', ''),
+            'version' => trim(file_get_contents(base_path('version'))),
+        ];
+
+        $zipFile = tempnam(sys_get_temp_dir(), 'bszip-');
+        $zip = new ZipArchive();
+        $opened = $zip->open($zipFile, ZipArchive::CREATE);
+        if ($opened !== true) {
+            throw new ZipExportException('Failed to create zip file for export.');
+        }
+
+        $zip->addFromString('data.json', json_encode($this->data));
+        $zip->addEmptyDir('files');
+
+        $toRemove = [];
+        $this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove) {
+            $zip->addFile($filePath, "files/$fileRef");
+            $toRemove[] = $filePath;
+        });
+
+        $zip->close();
+
+        foreach ($toRemove as $file) {
+            unlink($file);
+        }
+
+        return $zipFile;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipExportFiles.php b/app/Exports/ZipExports/ZipExportFiles.php
new file mode 100644 (file)
index 0000000..8f0a6bd
--- /dev/null
@@ -0,0 +1,107 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use BookStack\Uploads\Attachment;
+use BookStack\Uploads\AttachmentService;
+use BookStack\Uploads\Image;
+use BookStack\Uploads\ImageService;
+use Illuminate\Support\Str;
+
+class ZipExportFiles
+{
+    /**
+     * References for attachments by attachment ID.
+     * @var array<int, string>
+     */
+    protected array $attachmentRefsById = [];
+
+    /**
+     * References for images by image ID.
+     * @var array<int, string>
+     */
+    protected array $imageRefsById = [];
+
+    public function __construct(
+        protected AttachmentService $attachmentService,
+        protected ImageService $imageService,
+    ) {
+    }
+
+    /**
+     * Gain a reference to the given attachment instance.
+     * This is expected to be a file-based attachment that the user
+     * has visibility of, no permission/access checks are performed here.
+     */
+    public function referenceForAttachment(Attachment $attachment): string
+    {
+        if (isset($this->attachmentRefsById[$attachment->id])) {
+            return $this->attachmentRefsById[$attachment->id];
+        }
+
+        $existingFiles = $this->getAllFileNames();
+        do {
+            $fileName = Str::random(20) . '.' . $attachment->extension;
+        } while (in_array($fileName, $existingFiles));
+
+        $this->attachmentRefsById[$attachment->id] = $fileName;
+
+        return $fileName;
+    }
+
+    /**
+     * Gain a reference to the given image instance.
+     * This is expected to be an image that the user has visibility of,
+     * no permission/access checks are performed here.
+     */
+    public function referenceForImage(Image $image): string
+    {
+        if (isset($this->imageRefsById[$image->id])) {
+            return $this->imageRefsById[$image->id];
+        }
+
+        $existingFiles = $this->getAllFileNames();
+        $extension = pathinfo($image->path, PATHINFO_EXTENSION);
+        do {
+            $fileName = Str::random(20) . '.' . $extension;
+        } while (in_array($fileName, $existingFiles));
+
+        $this->imageRefsById[$image->id] = $fileName;
+
+        return $fileName;
+    }
+
+    protected function getAllFileNames(): array
+    {
+        return array_merge(
+            array_values($this->attachmentRefsById),
+            array_values($this->imageRefsById),
+        );
+    }
+
+    /**
+     * Extract each of the ZIP export tracked files.
+     * Calls the given callback for each tracked file, passing a temporary
+     * file reference of the file contents, and the zip-local tracked reference.
+     */
+    public function extractEach(callable $callback): void
+    {
+        foreach ($this->attachmentRefsById as $attachmentId => $ref) {
+            $attachment = Attachment::query()->find($attachmentId);
+            $stream = $this->attachmentService->streamAttachmentFromStorage($attachment);
+            $tmpFile = tempnam(sys_get_temp_dir(), 'bszipfile-');
+            $tmpFileStream = fopen($tmpFile, 'w');
+            stream_copy_to_stream($stream, $tmpFileStream);
+            $callback($tmpFile, $ref);
+        }
+
+        foreach ($this->imageRefsById as $imageId => $ref) {
+            $image = Image::query()->find($imageId);
+            $stream = $this->imageService->getImageStream($image);
+            $tmpFile = tempnam(sys_get_temp_dir(), 'bszipimage-');
+            $tmpFileStream = fopen($tmpFile, 'w');
+            stream_copy_to_stream($stream, $tmpFileStream);
+            $callback($tmpFile, $ref);
+        }
+    }
+}
diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php
new file mode 100644 (file)
index 0000000..c3d5c23
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use BookStack\Exceptions\ZipExportException;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use BookStack\Util\WebSafeMimeSniffer;
+use ZipArchive;
+
+class ZipExportReader
+{
+    protected ZipArchive $zip;
+    protected bool $open = false;
+
+    public function __construct(
+        protected string $zipPath,
+    ) {
+        $this->zip = new ZipArchive();
+    }
+
+    /**
+     * @throws ZipExportException
+     */
+    protected function open(): void
+    {
+        if ($this->open) {
+            return;
+        }
+
+        // Validate file exists
+        if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) {
+            throw new ZipExportException(trans('errors.import_zip_cant_read'));
+        }
+
+        // Validate file is valid zip
+        $opened = $this->zip->open($this->zipPath, ZipArchive::RDONLY);
+        if ($opened !== true) {
+            throw new ZipExportException(trans('errors.import_zip_cant_read'));
+        }
+
+        $this->open = true;
+    }
+
+    public function close(): void
+    {
+        if ($this->open) {
+            $this->zip->close();
+            $this->open = false;
+        }
+    }
+
+    /**
+     * @throws ZipExportException
+     */
+    public function readData(): array
+    {
+        $this->open();
+
+        // Validate json data exists, including metadata
+        $jsonData = $this->zip->getFromName('data.json') ?: '';
+        $importData = json_decode($jsonData, true);
+        if (!$importData) {
+            throw new ZipExportException(trans('errors.import_zip_cant_decode_data'));
+        }
+
+        return $importData;
+    }
+
+    public function fileExists(string $fileName): bool
+    {
+        return $this->zip->statName("files/{$fileName}") !== false;
+    }
+
+    /**
+     * @return false|resource
+     */
+    public function streamFile(string $fileName)
+    {
+        return $this->zip->getStream("files/{$fileName}");
+    }
+
+    /**
+     * Sniff the mime type from the file of given name.
+     */
+    public function sniffFileMime(string $fileName): string
+    {
+        $stream = $this->streamFile($fileName);
+        $sniffContent = fread($stream, 2000);
+
+        return (new WebSafeMimeSniffer())->sniff($sniffContent);
+    }
+
+    /**
+     * @throws ZipExportException
+     */
+    public function decodeDataToExportModel(): ZipExportBook|ZipExportChapter|ZipExportPage
+    {
+        $data = $this->readData();
+        if (isset($data['book'])) {
+            return ZipExportBook::fromArray($data['book']);
+        } else if (isset($data['chapter'])) {
+            return ZipExportChapter::fromArray($data['chapter']);
+        } else if (isset($data['page'])) {
+            return ZipExportPage::fromArray($data['page']);
+        }
+
+        throw new ZipExportException("Could not identify content in ZIP file data.");
+    }
+}
diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php
new file mode 100644 (file)
index 0000000..bf5e021
--- /dev/null
@@ -0,0 +1,159 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use BookStack\App\Model;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Exports\ZipExports\Models\ZipExportAttachment;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportImage;
+use BookStack\Exports\ZipExports\Models\ZipExportModel;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use BookStack\Uploads\Attachment;
+use BookStack\Uploads\Image;
+
+class ZipExportReferences
+{
+    /** @var ZipExportPage[] */
+    protected array $pages = [];
+    /** @var ZipExportChapter[] */
+    protected array $chapters = [];
+    /** @var ZipExportBook[] */
+    protected array $books = [];
+
+    /** @var ZipExportAttachment[] */
+    protected array $attachments = [];
+
+    /** @var ZipExportImage[] */
+    protected array $images = [];
+
+    public function __construct(
+        protected ZipReferenceParser $parser,
+    ) {
+    }
+
+    public function addPage(ZipExportPage $page): void
+    {
+        if ($page->id) {
+            $this->pages[$page->id] = $page;
+        }
+
+        foreach ($page->attachments as $attachment) {
+            if ($attachment->id) {
+                $this->attachments[$attachment->id] = $attachment;
+            }
+        }
+    }
+
+    public function addChapter(ZipExportChapter $chapter): void
+    {
+        if ($chapter->id) {
+            $this->chapters[$chapter->id] = $chapter;
+        }
+
+        foreach ($chapter->pages as $page) {
+            $this->addPage($page);
+        }
+    }
+
+    public function addBook(ZipExportBook $book): void
+    {
+        if ($book->id) {
+            $this->books[$book->id] = $book;
+        }
+
+        foreach ($book->pages as $page) {
+            $this->addPage($page);
+        }
+
+        foreach ($book->chapters as $chapter) {
+            $this->addChapter($chapter);
+        }
+    }
+
+    public function buildReferences(ZipExportFiles $files): void
+    {
+        $createHandler = function (ZipExportModel $zipModel) use ($files) {
+            return function (Model $model) use ($files, $zipModel) {
+                return $this->handleModelReference($model, $zipModel, $files);
+            };
+        };
+
+        // Parse page content first
+        foreach ($this->pages as $page) {
+            $handler = $createHandler($page);
+            $page->html = $this->parser->parseLinks($page->html ?? '', $handler);
+            if ($page->markdown) {
+                $page->markdown = $this->parser->parseLinks($page->markdown, $handler);
+            }
+        }
+
+        // Parse chapter description HTML
+        foreach ($this->chapters as $chapter) {
+            if ($chapter->description_html) {
+                $handler = $createHandler($chapter);
+                $chapter->description_html = $this->parser->parseLinks($chapter->description_html, $handler);
+            }
+        }
+
+        // Parse book description HTML
+        foreach ($this->books as $book) {
+            if ($book->description_html) {
+                $handler = $createHandler($book);
+                $book->description_html = $this->parser->parseLinks($book->description_html, $handler);
+            }
+        }
+    }
+
+    protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string
+    {
+        // Handle attachment references
+        // No permission check needed here since they would only already exist in this
+        // reference context if already allowed via their entity access.
+        if ($model instanceof Attachment) {
+            if (isset($this->attachments[$model->id])) {
+                return "[[bsexport:attachment:{$model->id}]]";
+            }
+            return null;
+        }
+
+        // Handle image references
+        if ($model instanceof Image) {
+            // Only handle gallery and drawio images
+            if ($model->type !== 'gallery' && $model->type !== 'drawio') {
+                return null;
+            }
+
+            // Handle simple links outside of page content
+            if (!($exportModel instanceof ZipExportPage) && isset($this->images[$model->id])) {
+                return "[[bsexport:image:{$model->id}]]";
+            }
+
+            // Find and include images if in visibility
+            $page = $model->getPage();
+            if ($page && userCan('view', $page)) {
+                if (!isset($this->images[$model->id])) {
+                    $exportImage = ZipExportImage::fromModel($model, $files);
+                    $this->images[$model->id] = $exportImage;
+                    $exportModel->images[] = $exportImage;
+                }
+                return "[[bsexport:image:{$model->id}]]";
+            }
+            return null;
+        }
+
+        // Handle entity references
+        if ($model instanceof Book && isset($this->books[$model->id])) {
+            return "[[bsexport:book:{$model->id}]]";
+        } else if ($model instanceof Chapter && isset($this->chapters[$model->id])) {
+            return "[[bsexport:chapter:{$model->id}]]";
+        } else if ($model instanceof Page && isset($this->pages[$model->id])) {
+            return "[[bsexport:page:{$model->id}]]";
+        }
+
+        return null;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php
new file mode 100644 (file)
index 0000000..889804f
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use BookStack\Exceptions\ZipExportException;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+
+class ZipExportValidator
+{
+    public function __construct(
+        protected ZipExportReader $reader,
+    ) {
+    }
+
+    public function validate(): array
+    {
+        try {
+            $importData = $this->reader->readData();
+        } catch (ZipExportException $exception) {
+            return ['format' => $exception->getMessage()];
+        }
+
+        $helper = new ZipValidationHelper($this->reader);
+
+        if (isset($importData['book'])) {
+            $modelErrors = ZipExportBook::validate($helper, $importData['book']);
+            $keyPrefix = 'book';
+        } else if (isset($importData['chapter'])) {
+            $modelErrors = ZipExportChapter::validate($helper, $importData['chapter']);
+            $keyPrefix = 'chapter';
+        } else if (isset($importData['page'])) {
+            $modelErrors = ZipExportPage::validate($helper, $importData['page']);
+            $keyPrefix = 'page';
+        } else {
+            return ['format' => trans('errors.import_zip_no_data')];
+        }
+
+        return $this->flattenModelErrors($modelErrors, $keyPrefix);
+    }
+
+    protected function flattenModelErrors(array $errors, string $keyPrefix): array
+    {
+        $flattened = [];
+
+        foreach ($errors as $key => $error) {
+            if (is_array($error)) {
+                $flattened = array_merge($flattened, $this->flattenModelErrors($error, $keyPrefix . '.' . $key));
+            } else {
+                $flattened[$keyPrefix . '.' . $key] = $error;
+            }
+        }
+
+        return $flattened;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php
new file mode 100644 (file)
index 0000000..90e78c0
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class ZipFileReferenceRule implements ValidationRule
+{
+    public function __construct(
+        protected ZipValidationHelper $context,
+        protected array $acceptedMimes,
+    ) {
+    }
+
+
+    /**
+     * @inheritDoc
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        if (!$this->context->zipReader->fileExists($value)) {
+            $fail('validation.zip_file')->translate();
+        }
+
+        if (!empty($this->acceptedMimes)) {
+            $fileMime = $this->context->zipReader->sniffFileMime($value);
+            if (!in_array($fileMime, $this->acceptedMimes)) {
+                $fail('validation.zip_file_mime')->translate([
+                    'attribute' => $attribute,
+                    'validTypes' => implode(',', $this->acceptedMimes),
+                    'foundType' => $fileMime
+                ]);
+            }
+        }
+    }
+}
diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php
new file mode 100644 (file)
index 0000000..da0581d
--- /dev/null
@@ -0,0 +1,161 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use BookStack\App\Model;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\BaseRepo;
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use BookStack\Uploads\Attachment;
+use BookStack\Uploads\Image;
+use BookStack\Uploads\ImageResizer;
+
+class ZipImportReferences
+{
+    /** @var Page[] */
+    protected array $pages = [];
+    /** @var Chapter[] */
+    protected array $chapters = [];
+    /** @var Book[] */
+    protected array $books = [];
+    /** @var Attachment[] */
+    protected array $attachments = [];
+    /** @var Image[] */
+    protected array $images = [];
+
+    /** @var array<string, Model> */
+    protected array $referenceMap = [];
+
+    /** @var array<int, ZipExportPage> */
+    protected array $zipExportPageMap = [];
+    /** @var array<int, ZipExportChapter> */
+    protected array $zipExportChapterMap = [];
+    /** @var array<int, ZipExportBook> */
+    protected array $zipExportBookMap = [];
+
+    public function __construct(
+        protected ZipReferenceParser $parser,
+        protected BaseRepo $baseRepo,
+        protected PageRepo $pageRepo,
+        protected ImageResizer $imageResizer,
+    ) {
+    }
+
+    protected function addReference(string $type, Model $model, ?int $importId): void
+    {
+        if ($importId) {
+            $key = $type . ':' . $importId;
+            $this->referenceMap[$key] = $model;
+        }
+    }
+
+    public function addPage(Page $page, ZipExportPage $exportPage): void
+    {
+        $this->pages[] = $page;
+        $this->zipExportPageMap[$page->id] = $exportPage;
+        $this->addReference('page', $page, $exportPage->id);
+    }
+
+    public function addChapter(Chapter $chapter, ZipExportChapter $exportChapter): void
+    {
+        $this->chapters[] = $chapter;
+        $this->zipExportChapterMap[$chapter->id] = $exportChapter;
+        $this->addReference('chapter', $chapter, $exportChapter->id);
+    }
+
+    public function addBook(Book $book, ZipExportBook $exportBook): void
+    {
+        $this->books[] = $book;
+        $this->zipExportBookMap[$book->id] = $exportBook;
+        $this->addReference('book', $book, $exportBook->id);
+    }
+
+    public function addAttachment(Attachment $attachment, ?int $importId): void
+    {
+        $this->attachments[] = $attachment;
+        $this->addReference('attachment', $attachment, $importId);
+    }
+
+    public function addImage(Image $image, ?int $importId): void
+    {
+        $this->images[] = $image;
+        $this->addReference('image', $image, $importId);
+    }
+
+    protected function handleReference(string $type, int $id): ?string
+    {
+        $key = $type . ':' . $id;
+        $model = $this->referenceMap[$key] ?? null;
+        if ($model instanceof Entity) {
+            return $model->getUrl();
+        } else if ($model instanceof Image) {
+            if ($model->type === 'gallery') {
+                $this->imageResizer->loadGalleryThumbnailsForImage($model, false);
+                return $model->thumbs['display'] ?? $model->url;
+            }
+
+            return $model->url;
+        } else if ($model instanceof Attachment) {
+            return $model->getUrl(false);
+        }
+
+        return null;
+    }
+
+    public function replaceReferences(): void
+    {
+        foreach ($this->books as $book) {
+            $exportBook = $this->zipExportBookMap[$book->id];
+            $content = $exportBook->description_html ?? '';
+            $parsed = $this->parser->parseReferences($content, $this->handleReference(...));
+
+            $this->baseRepo->update($book, [
+                'description_html' => $parsed,
+            ]);
+        }
+
+        foreach ($this->chapters as $chapter) {
+            $exportChapter = $this->zipExportChapterMap[$chapter->id];
+            $content = $exportChapter->description_html ?? '';
+            $parsed = $this->parser->parseReferences($content, $this->handleReference(...));
+
+            $this->baseRepo->update($chapter, [
+                'description_html' => $parsed,
+            ]);
+        }
+
+        foreach ($this->pages as $page) {
+            $exportPage = $this->zipExportPageMap[$page->id];
+            $contentType = $exportPage->markdown ? 'markdown' : 'html';
+            $content = $exportPage->markdown ?: ($exportPage->html ?: '');
+            $parsed = $this->parser->parseReferences($content, $this->handleReference(...));
+
+            $this->pageRepo->setContentFromInput($page, [
+                $contentType => $parsed,
+            ]);
+        }
+    }
+
+
+    /**
+     * @return Image[]
+     */
+    public function images(): array
+    {
+        return $this->images;
+    }
+
+    /**
+     * @return Attachment[]
+     */
+    public function attachments(): array
+    {
+        return $this->attachments;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php
new file mode 100644 (file)
index 0000000..d25a162
--- /dev/null
@@ -0,0 +1,364 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Repos\ChapterRepo;
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Exceptions\ZipExportException;
+use BookStack\Exceptions\ZipImportException;
+use BookStack\Exports\Import;
+use BookStack\Exports\ZipExports\Models\ZipExportAttachment;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportImage;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use BookStack\Exports\ZipExports\Models\ZipExportTag;
+use BookStack\Uploads\Attachment;
+use BookStack\Uploads\AttachmentService;
+use BookStack\Uploads\FileStorage;
+use BookStack\Uploads\Image;
+use BookStack\Uploads\ImageService;
+use Illuminate\Http\UploadedFile;
+
+class ZipImportRunner
+{
+    protected array $tempFilesToCleanup = [];
+
+    public function __construct(
+        protected FileStorage $storage,
+        protected PageRepo $pageRepo,
+        protected ChapterRepo $chapterRepo,
+        protected BookRepo $bookRepo,
+        protected ImageService $imageService,
+        protected AttachmentService $attachmentService,
+        protected ZipImportReferences $references,
+    ) {
+    }
+
+    /**
+     * Run the import.
+     * Performs re-validation on zip, validation on parent provided, and permissions for importing
+     * the planned content, before running the import process.
+     * Returns the top-level entity item which was imported.
+     * @throws ZipImportException
+     */
+    public function run(Import $import, ?Entity $parent = null): Entity
+    {
+        $zipPath = $this->getZipPath($import);
+        $reader = new ZipExportReader($zipPath);
+
+        $errors = (new ZipExportValidator($reader))->validate();
+        if ($errors) {
+            throw new ZipImportException([
+                trans('errors.import_validation_failed'),
+                ...$errors,
+            ]);
+        }
+
+        try {
+            $exportModel = $reader->decodeDataToExportModel();
+        } catch (ZipExportException $e) {
+            throw new ZipImportException([$e->getMessage()]);
+        }
+
+        // Validate parent type
+        if ($exportModel instanceof ZipExportBook && ($parent !== null)) {
+            throw new ZipImportException(["Must not have a parent set for a Book import."]);
+        } else if ($exportModel instanceof ZipExportChapter && !($parent instanceof Book)) {
+            throw new ZipImportException(["Parent book required for chapter import."]);
+        } else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) {
+            throw new ZipImportException(["Parent book or chapter required for page import."]);
+        }
+
+        $this->ensurePermissionsPermitImport($exportModel, $parent);
+
+        if ($exportModel instanceof ZipExportBook) {
+            $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.']);
+        }
+
+        $this->references->replaceReferences();
+
+        $reader->close();
+        $this->cleanup();
+
+        return $entity;
+    }
+
+    /**
+     * Revert any files which have been stored during this import process.
+     * Considers files only, and avoids the database under the
+     * assumption that the database may already have been
+     * reverted as part of a transaction rollback.
+     */
+    public function revertStoredFiles(): void
+    {
+        foreach ($this->references->images() as $image) {
+            $this->imageService->destroyFileAtPath($image->type, $image->path);
+        }
+
+        foreach ($this->references->attachments() as $attachment) {
+            if (!$attachment->external) {
+                $this->attachmentService->deleteFileInStorage($attachment);
+            }
+        }
+
+        $this->cleanup();
+    }
+
+    protected function cleanup(): void
+    {
+        foreach ($this->tempFilesToCleanup as $file) {
+            unlink($file);
+        }
+
+        $this->tempFilesToCleanup = [];
+    }
+
+    protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book
+    {
+        $book = $this->bookRepo->create([
+            'name' => $exportBook->name,
+            'description_html' => $exportBook->description_html ?? '',
+            'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null,
+            'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
+        ]);
+
+        if ($book->cover) {
+            $this->references->addImage($book->cover, null);
+        }
+
+        $children = [
+            ...$exportBook->chapters,
+            ...$exportBook->pages,
+        ];
+
+        usort($children, function (ZipExportPage|ZipExportChapter $a, ZipExportPage|ZipExportChapter $b) {
+            return ($a->priority ?? 0) - ($b->priority ?? 0);
+        });
+
+        foreach ($children as $child) {
+            if ($child instanceof ZipExportChapter) {
+                $this->importChapter($child, $book, $reader);
+            } else if ($child instanceof ZipExportPage) {
+                $this->importPage($child, $book, $reader);
+            }
+        }
+
+        $this->references->addBook($book, $exportBook);
+
+        return $book;
+    }
+
+    protected function importChapter(ZipExportChapter $exportChapter, Book $parent, ZipExportReader $reader): Chapter
+    {
+        $chapter = $this->chapterRepo->create([
+            'name' => $exportChapter->name,
+            'description_html' => $exportChapter->description_html ?? '',
+            'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []),
+        ], $parent);
+
+        $exportPages = $exportChapter->pages;
+        usort($exportPages, function (ZipExportPage $a, ZipExportPage $b) {
+            return ($a->priority ?? 0) - ($b->priority ?? 0);
+        });
+
+        foreach ($exportPages as $exportPage) {
+            $this->importPage($exportPage, $chapter, $reader);
+        }
+
+        $this->references->addChapter($chapter, $exportChapter);
+
+        return $chapter;
+    }
+
+    protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, ZipExportReader $reader): Page
+    {
+        $page = $this->pageRepo->getNewDraftPage($parent);
+
+        foreach ($exportPage->attachments as $exportAttachment) {
+            $this->importAttachment($exportAttachment, $page, $reader);
+        }
+
+        foreach ($exportPage->images as $exportImage) {
+            $this->importImage($exportImage, $page, $reader);
+        }
+
+        $this->pageRepo->publishDraft($page, [
+            'name' => $exportPage->name,
+            'markdown' => $exportPage->markdown,
+            'html' => $exportPage->html,
+            'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
+        ]);
+
+        $this->references->addPage($page, $exportPage);
+
+        return $page;
+    }
+
+    protected function importAttachment(ZipExportAttachment $exportAttachment, Page $page, ZipExportReader $reader): Attachment
+    {
+        if ($exportAttachment->file) {
+            $file = $this->zipFileToUploadedFile($exportAttachment->file, $reader);
+            $attachment = $this->attachmentService->saveNewUpload($file, $page->id);
+            $attachment->name = $exportAttachment->name;
+            $attachment->save();
+        } else {
+            $attachment = $this->attachmentService->saveNewFromLink(
+                $exportAttachment->name,
+                $exportAttachment->link ?? '',
+                $page->id,
+            );
+        }
+
+        $this->references->addAttachment($attachment, $exportAttachment->id);
+
+        return $attachment;
+    }
+
+    protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image
+    {
+        $mime = $reader->sniffFileMime($exportImage->file);
+        $extension = explode('/', $mime)[1];
+
+        $file = $this->zipFileToUploadedFile($exportImage->file, $reader);
+        $image = $this->imageService->saveNewFromUpload(
+            $file,
+            $exportImage->type,
+            $page->id,
+            null,
+            null,
+            true,
+            $exportImage->name . '.' . $extension,
+        );
+
+        $image->name = $exportImage->name;
+        $image->save();
+
+        $this->references->addImage($image, $exportImage->id);
+
+        return $image;
+    }
+
+    protected function exportTagsToInputArray(array $exportTags): array
+    {
+        $tags = [];
+
+        /** @var ZipExportTag $tag */
+        foreach ($exportTags as $tag) {
+            $tags[] = ['name' => $tag->name, 'value' => $tag->value ?? ''];
+        }
+
+        return $tags;
+    }
+
+    protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
+    {
+        $tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
+        $fileStream = $reader->streamFile($fileName);
+        $tempStream = fopen($tempPath, 'wb');
+        stream_copy_to_stream($fileStream, $tempStream);
+        fclose($tempStream);
+
+        $this->tempFilesToCleanup[] = $tempPath;
+
+        return new UploadedFile($tempPath, $fileName);
+    }
+
+    /**
+     * @throws ZipImportException
+     */
+    protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter|ZipExportBook $exportModel, Book|Chapter|null $parent = null): void
+    {
+        $errors = [];
+
+        $chapters = [];
+        $pages = [];
+        $images = [];
+        $attachments = [];
+
+        if ($exportModel instanceof ZipExportBook) {
+            if (!userCan('book-create-all')) {
+                $errors[] = trans('errors.import_perms_books');
+            }
+            array_push($pages, ...$exportModel->pages);
+            array_push($chapters, ...$exportModel->chapters);
+        } else if ($exportModel instanceof ZipExportChapter) {
+            $chapters[] = $exportModel;
+        } else if ($exportModel instanceof ZipExportPage) {
+            $pages[] = $exportModel;
+        }
+
+        foreach ($chapters as $chapter) {
+            array_push($pages, ...$chapter->pages);
+        }
+
+        if (count($chapters) > 0) {
+            $permission = 'chapter-create' . ($parent ? '' : '-all');
+            if (!userCan($permission, $parent)) {
+                $errors[] = trans('errors.import_perms_chapters');
+            }
+        }
+
+        foreach ($pages as $page) {
+            array_push($attachments, ...$page->attachments);
+            array_push($images, ...$page->images);
+        }
+
+        if (count($pages) > 0) {
+            if ($parent) {
+                if (!userCan('page-create', $parent)) {
+                    $errors[] = trans('errors.import_perms_pages');
+                }
+            } else {
+                $hasPermission = userCan('page-create-all') || userCan('page-create-own');
+                if (!$hasPermission) {
+                    $errors[] = trans('errors.import_perms_pages');
+                }
+            }
+        }
+
+        if (count($images) > 0) {
+            if (!userCan('image-create-all')) {
+                $errors[] = trans('errors.import_perms_images');
+            }
+        }
+
+        if (count($attachments) > 0) {
+            if (!userCan('attachment-create-all')) {
+                $errors[] = trans('errors.import_perms_attachments');
+            }
+        }
+
+        if (count($errors)) {
+            throw new ZipImportException($errors);
+        }
+    }
+
+    protected function getZipPath(Import $import): string
+    {
+        if (!$this->storage->isRemote()) {
+            return $this->storage->getSystemPath($import->path);
+        }
+
+        $tempFilePath = tempnam(sys_get_temp_dir(), 'bszip-import-');
+        $tempFile = fopen($tempFilePath, 'wb');
+        $stream = $this->storage->getReadStream($import->path);
+        stream_copy_to_stream($stream, $tempFile);
+        fclose($tempFile);
+
+        $this->tempFilesToCleanup[] = $tempFilePath;
+
+        return $tempFilePath;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php
new file mode 100644 (file)
index 0000000..a6560e3
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use BookStack\App\Model;
+use BookStack\Entities\Queries\EntityQueries;
+use BookStack\References\ModelResolvers\AttachmentModelResolver;
+use BookStack\References\ModelResolvers\BookLinkModelResolver;
+use BookStack\References\ModelResolvers\ChapterLinkModelResolver;
+use BookStack\References\ModelResolvers\CrossLinkModelResolver;
+use BookStack\References\ModelResolvers\ImageModelResolver;
+use BookStack\References\ModelResolvers\PageLinkModelResolver;
+use BookStack\References\ModelResolvers\PagePermalinkModelResolver;
+use BookStack\Uploads\ImageStorage;
+
+class ZipReferenceParser
+{
+    /**
+     * @var CrossLinkModelResolver[]|null
+     */
+    protected ?array $modelResolvers = null;
+
+    public function __construct(
+        protected EntityQueries $queries
+    ) {
+    }
+
+    /**
+     * Parse and replace references in the given content.
+     * Calls the handler for each model link detected and replaces the link
+     * with the handler return value if provided.
+     * Returns the resulting content with links replaced.
+     * @param callable(Model):(string|null) $handler
+     */
+    public function parseLinks(string $content, callable $handler): string
+    {
+        $linkRegex = $this->getLinkRegex();
+        $matches = [];
+        preg_match_all($linkRegex, $content, $matches);
+
+        if (count($matches) < 2) {
+            return $content;
+        }
+
+        foreach ($matches[1] as $link) {
+            $model = $this->linkToModel($link);
+            if ($model) {
+                $result = $handler($model);
+                if ($result !== null) {
+                    $content = str_replace($link, $result, $content);
+                }
+            }
+        }
+
+        return $content;
+    }
+
+    /**
+     * Parse and replace references in the given content.
+     * Calls the handler for each reference detected and replaces the link
+     * with the handler return value if provided.
+     * Returns the resulting content string with references replaced.
+     * @param callable(string $type, int $id):(string|null) $handler
+     */
+    public function parseReferences(string $content, callable $handler): string
+    {
+        $referenceRegex = '/\[\[bsexport:([a-z]+):(\d+)]]/';
+        $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]);
+            $id = intval($matches[2][$i]);
+            $result = $handler($type, $id);
+            if ($result !== null) {
+                $content = str_replace($referenceText, $result, $content);
+            }
+        }
+
+        return $content;
+    }
+
+
+    /**
+     * Attempt to resolve the given link to a model using the instance model resolvers.
+     */
+    protected function linkToModel(string $link): ?Model
+    {
+        foreach ($this->getModelResolvers() as $resolver) {
+            $model = $resolver->resolve($link);
+            if (!is_null($model)) {
+                return $model;
+            }
+        }
+
+        return null;
+    }
+
+    protected function getModelResolvers(): array
+    {
+        if (isset($this->modelResolvers)) {
+            return $this->modelResolvers;
+        }
+
+        $this->modelResolvers = [
+            new PagePermalinkModelResolver($this->queries->pages),
+            new PageLinkModelResolver($this->queries->pages),
+            new ChapterLinkModelResolver($this->queries->chapters),
+            new BookLinkModelResolver($this->queries->books),
+            new ImageModelResolver(),
+            new AttachmentModelResolver(),
+        ];
+
+        return $this->modelResolvers;
+    }
+
+    /**
+     * Build the regex to identify links we should handle in content.
+     */
+    protected function getLinkRegex(): string
+    {
+        $urls = [rtrim(url('/'), '/')];
+        $imageUrl = rtrim(ImageStorage::getPublicUrl('/'), '/');
+        if ($urls[0] !== $imageUrl) {
+            $urls[] = $imageUrl;
+        }
+
+
+        $urlBaseRegex = implode('|', array_map(function ($url) {
+            return preg_quote($url, '/');
+        }, $urls));
+
+        return "/(({$urlBaseRegex}).*?)[\\t\\n\\f>\"'=?#()]/";
+    }
+}
diff --git a/app/Exports/ZipExports/ZipUniqueIdRule.php b/app/Exports/ZipExports/ZipUniqueIdRule.php
new file mode 100644 (file)
index 0000000..ea2b253
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use Closure;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class ZipUniqueIdRule implements ValidationRule
+{
+    public function __construct(
+        protected ZipValidationHelper $context,
+        protected string $modelType,
+    ) {
+    }
+
+
+    /**
+     * @inheritDoc
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        if ($this->context->hasIdBeenUsed($this->modelType, $value)) {
+            $fail('validation.zip_unique')->translate(['attribute' => $attribute]);
+        }
+    }
+}
diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php
new file mode 100644 (file)
index 0000000..fd9cd78
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use BookStack\Exports\ZipExports\Models\ZipExportModel;
+use Illuminate\Validation\Factory;
+
+class ZipValidationHelper
+{
+    protected Factory $validationFactory;
+
+    /**
+     * Local store of validated IDs (in format "<type>:<id>". Example: "book:2")
+     * which we can use to check uniqueness.
+     * @var array<string, bool>
+     */
+    protected array $validatedIds = [];
+
+    public function __construct(
+        public ZipExportReader $zipReader,
+    ) {
+        $this->validationFactory = app(Factory::class);
+    }
+
+    public function validateData(array $data, array $rules): array
+    {
+        $messages = $this->validationFactory->make($data, $rules)->errors()->messages();
+
+        foreach ($messages as $key => $message) {
+            $messages[$key] = implode("\n", $message);
+        }
+
+        return $messages;
+    }
+
+    public function fileReferenceRule(array $acceptedMimes = []): ZipFileReferenceRule
+    {
+        return new ZipFileReferenceRule($this, $acceptedMimes);
+    }
+
+    public function uniqueIdRule(string $type): ZipUniqueIdRule
+    {
+        return new ZipUniqueIdRule($this, $type);
+    }
+
+    public function hasIdBeenUsed(string $type, mixed $id): bool
+    {
+        $key = $type . ':' . $id;
+        if (isset($this->validatedIds[$key])) {
+            return true;
+        }
+
+        $this->validatedIds[$key] = true;
+
+        return false;
+    }
+
+    /**
+     * Validate an array of relation data arrays that are expected
+     * to be for the given ZipExportModel.
+     * @param class-string<ZipExportModel> $model
+     */
+    public function validateRelations(array $relations, string $model): array
+    {
+        $results = [];
+
+        foreach ($relations as $key => $relationData) {
+            if (is_array($relationData)) {
+                $results[$key] = $model::validate($this, $relationData);
+            } else {
+                $results[$key] = [trans('validation.zip_model_expected', ['type' => gettype($relationData)])];
+            }
+        }
+
+        return $results;
+    }
+}
index 8facf5dab3c3331d1538b373b68b269d6c6f53b4..090cf523ad28051751f0cca3b325890cf99e7332 100644 (file)
@@ -152,10 +152,8 @@ abstract class Controller extends BaseController
 
     /**
      * Log an activity in the system.
-     *
-     * @param string|Loggable $detail
      */
-    protected function logActivity(string $type, $detail = ''): void
+    protected function logActivity(string $type, string|Loggable $detail = ''): void
     {
         Activity::add($type, $detail);
     }
index 300f4488cf44453d4f18f5c27207da8b6f12ed1b..fce1e9acce30cbc187756c5ef9dd54b7e79f51dd 100644 (file)
@@ -92,7 +92,7 @@ class RangeSupportedStream
             if ($start < 0 || $start > $end) {
                 $this->responseStatus = 416;
                 $this->responseHeaders['Content-Range'] = sprintf('bytes */%s', $this->fileSize);
-            } elseif ($end - $start < $this->fileSize - 1) {
+            } else {
                 $this->responseLength = $end < $this->fileSize ? $end - $start + 1 : -1;
                 $this->responseOffset = $start;
                 $this->responseStatus = 206;
diff --git a/app/References/ModelResolvers/AttachmentModelResolver.php b/app/References/ModelResolvers/AttachmentModelResolver.php
new file mode 100644 (file)
index 0000000..e870d51
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace BookStack\References\ModelResolvers;
+
+use BookStack\Uploads\Attachment;
+
+class AttachmentModelResolver implements CrossLinkModelResolver
+{
+    public function resolve(string $link): ?Attachment
+    {
+        $pattern = '/^' . preg_quote(url('/attachments'), '/') . '\/(\d+)/';
+        $matches = [];
+        $match = preg_match($pattern, $link, $matches);
+        if (!$match) {
+            return null;
+        }
+
+        $id = intval($matches[1]);
+
+        return Attachment::query()->find($id);
+    }
+}
diff --git a/app/References/ModelResolvers/ImageModelResolver.php b/app/References/ModelResolvers/ImageModelResolver.php
new file mode 100644 (file)
index 0000000..2c6c9fe
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+namespace BookStack\References\ModelResolvers;
+
+use BookStack\Uploads\Image;
+use BookStack\Uploads\ImageStorage;
+
+class ImageModelResolver implements CrossLinkModelResolver
+{
+    protected ?string $pattern = null;
+
+    public function resolve(string $link): ?Image
+    {
+        $pattern = $this->getUrlPattern();
+        $matches = [];
+        $match = preg_match($pattern, $link, $matches);
+        if (!$match) {
+            return null;
+        }
+
+        $path = $matches[2];
+
+        // Strip thumbnail element from path if existing
+        $originalPathSplit = array_filter(explode('/', $path), function (string $part) {
+            $resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-'));
+            $missingExtension = !str_contains($part, '.');
+
+            return !($resizedDir && $missingExtension);
+        });
+
+        // Build a database-format image path and search for the image entry
+        $fullPath = '/uploads/images/' . ltrim(implode('/', $originalPathSplit), '/');
+
+        return Image::query()->where('path', '=', $fullPath)->first();
+    }
+
+    /**
+     * Get the regex pattern to identify image URLs.
+     * Caches the pattern since it requires looking up to settings/config.
+     */
+    protected function getUrlPattern(): string
+    {
+        if ($this->pattern) {
+            return $this->pattern;
+        }
+
+        $urls = [url('/uploads/images')];
+        $baseImageUrl = ImageStorage::getPublicUrl('/uploads/images');
+        if ($baseImageUrl !== $urls[0]) {
+            $urls[] = $baseImageUrl;
+        }
+
+        $imageUrlRegex = implode('|', array_map(fn ($url) => preg_quote($url, '/'), $urls));
+        $this->pattern = '/^(' . $imageUrlRegex . ')\/(.+)/';
+
+        return $this->pattern;
+    }
+}
index e087dd9fa5e85c407ff6805d06cb386b1733f333..ca71df64931f189d4966db0d3123e8d8a7f8a19c 100644 (file)
@@ -60,6 +60,7 @@ class LocaleManager
         'sq'          => 'sq_AL',
         'sr'          => 'sr_RS',
         'sv'          => 'sv_SE',
+        'tk'          => 'tk_TM',
         'tr'          => 'tr_TR',
         'uk'          => 'uk_UA',
         'uz'          => 'uz_UZ',
index bd319fbd795af717c4c026d63b9b8bc58ea7fabd..033f2334104b44f008d0ce29ecdc84bc68a33117 100644 (file)
@@ -4,62 +4,13 @@ namespace BookStack\Uploads;
 
 use BookStack\Exceptions\FileUploadException;
 use Exception;
-use Illuminate\Contracts\Filesystem\Filesystem as Storage;
-use Illuminate\Filesystem\FilesystemManager;
-use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Str;
-use League\Flysystem\WhitespacePathNormalizer;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class AttachmentService
 {
-    protected FilesystemManager $fileSystem;
-
-    /**
-     * AttachmentService constructor.
-     */
-    public function __construct(FilesystemManager $fileSystem)
-    {
-        $this->fileSystem = $fileSystem;
-    }
-
-    /**
-     * Get the storage that will be used for storing files.
-     */
-    protected function getStorageDisk(): Storage
-    {
-        return $this->fileSystem->disk($this->getStorageDiskName());
-    }
-
-    /**
-     * Get the name of the storage disk to use.
-     */
-    protected function getStorageDiskName(): string
-    {
-        $storageType = config('filesystems.attachments');
-
-        // Change to our secure-attachment disk if any of the local options
-        // are used to prevent escaping that location.
-        if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
-            $storageType = 'local_secure_attachments';
-        }
-
-        return $storageType;
-    }
-
-    /**
-     * Change the originally provided path to fit any disk-specific requirements.
-     * This also ensures the path is kept to the expected root folders.
-     */
-    protected function adjustPathForStorageDisk(string $path): string
-    {
-        $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
-
-        if ($this->getStorageDiskName() === 'local_secure_attachments') {
-            return $path;
-        }
-
-        return 'uploads/files/' . $path;
+    public function __construct(
+        protected FileStorage $storage,
+    ) {
     }
 
     /**
@@ -69,7 +20,7 @@ class AttachmentService
      */
     public function streamAttachmentFromStorage(Attachment $attachment)
     {
-        return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path));
+        return $this->storage->getReadStream($attachment->path);
     }
 
     /**
@@ -77,7 +28,7 @@ class AttachmentService
      */
     public function getAttachmentFileSize(Attachment $attachment): int
     {
-        return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path));
+        return $this->storage->getSize($attachment->path);
     }
 
     /**
@@ -200,15 +151,9 @@ class AttachmentService
      * Delete a file from the filesystem it sits on.
      * Cleans any empty leftover folders.
      */
-    protected function deleteFileInStorage(Attachment $attachment)
+    public function deleteFileInStorage(Attachment $attachment): void
     {
-        $storage = $this->getStorageDisk();
-        $dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
-
-        $storage->delete($this->adjustPathForStorageDisk($attachment->path));
-        if (count($storage->allFiles($dirPath)) === 0) {
-            $storage->deleteDirectory($dirPath);
-        }
+        $this->storage->delete($attachment->path);
     }
 
     /**
@@ -218,32 +163,20 @@ class AttachmentService
      */
     protected function putFileInStorage(UploadedFile $uploadedFile): string
     {
-        $storage = $this->getStorageDisk();
         $basePath = 'uploads/files/' . date('Y-m-M') . '/';
 
-        $uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension();
-        while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
-            $uploadFileName = Str::random(3) . $uploadFileName;
-        }
-
-        $attachmentStream = fopen($uploadedFile->getRealPath(), 'r');
-        $attachmentPath = $basePath . $uploadFileName;
-
-        try {
-            $storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream);
-        } catch (Exception $e) {
-            Log::error('Error when attempting file upload:' . $e->getMessage());
-
-            throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath]));
-        }
-
-        return $attachmentPath;
+        return $this->storage->uploadFile(
+            $uploadedFile,
+            $basePath,
+            $uploadedFile->getClientOriginalExtension(),
+            ''
+        );
     }
 
     /**
      * Get the file validation rules for attachments.
      */
-    public function getFileValidationRules(): array
+    public static function getFileValidationRules(): array
     {
         return ['file', 'max:' . (config('app.upload_limit') * 1000)];
     }
diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php
new file mode 100644 (file)
index 0000000..e6ac368
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+
+namespace BookStack\Uploads;
+
+use BookStack\Exceptions\FileUploadException;
+use Exception;
+use Illuminate\Contracts\Filesystem\Filesystem as Storage;
+use Illuminate\Filesystem\FilesystemAdapter;
+use Illuminate\Filesystem\FilesystemManager;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+use League\Flysystem\WhitespacePathNormalizer;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+
+class FileStorage
+{
+    public function __construct(
+        protected FilesystemManager $fileSystem,
+    ) {
+    }
+
+    /**
+     * @return resource|null
+     */
+    public function getReadStream(string $path)
+    {
+        return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($path));
+    }
+
+    public function getSize(string $path): int
+    {
+        return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($path));
+    }
+
+    public function delete(string $path, bool $removeEmptyDir = false): void
+    {
+        $storage = $this->getStorageDisk();
+        $adjustedPath = $this->adjustPathForStorageDisk($path);
+        $dir = dirname($adjustedPath);
+
+        $storage->delete($adjustedPath);
+        if ($removeEmptyDir && count($storage->allFiles($dir)) === 0) {
+            $storage->deleteDirectory($dir);
+        }
+    }
+
+    /**
+     * @throws FileUploadException
+     */
+    public function uploadFile(UploadedFile $file, string $subDirectory, string $suffix, string $extension): string
+    {
+        $storage = $this->getStorageDisk();
+        $basePath = trim($subDirectory, '/') . '/';
+
+        $uploadFileName = Str::random(16) . ($suffix ? "-{$suffix}" : '') . ($extension ? ".{$extension}" : '');
+        while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
+            $uploadFileName = Str::random(3) . $uploadFileName;
+        }
+
+        $fileStream = fopen($file->getRealPath(), 'r');
+        $filePath = $basePath . $uploadFileName;
+
+        try {
+            $storage->writeStream($this->adjustPathForStorageDisk($filePath), $fileStream);
+        } catch (Exception $e) {
+            Log::error('Error when attempting file upload:' . $e->getMessage());
+
+            throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $filePath]));
+        }
+
+        return $filePath;
+    }
+
+    /**
+     * Check whether the configured storage is remote from the host of this app.
+     */
+    public function isRemote(): bool
+    {
+        return $this->getStorageDiskName() === 's3';
+    }
+
+    /**
+     * Get the actual path on system for the given relative file path.
+     */
+    public function getSystemPath(string $filePath): string
+    {
+        if ($this->isRemote()) {
+            return '';
+        }
+
+        return storage_path('uploads/files/' . ltrim($this->adjustPathForStorageDisk($filePath), '/'));
+    }
+
+    /**
+     * Get the storage that will be used for storing files.
+     */
+    protected function getStorageDisk(): Storage
+    {
+        return $this->fileSystem->disk($this->getStorageDiskName());
+    }
+
+    /**
+     * Get the name of the storage disk to use.
+     */
+    protected function getStorageDiskName(): string
+    {
+        $storageType = trim(strtolower(config('filesystems.attachments')));
+
+        // Change to our secure-attachment disk if any of the local options
+        // are used to prevent escaping that location.
+        if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
+            $storageType = 'local_secure_attachments';
+        }
+
+        return $storageType;
+    }
+
+    /**
+     * Change the originally provided path to fit any disk-specific requirements.
+     * This also ensures the path is kept to the expected root folders.
+     */
+    protected function adjustPathForStorageDisk(string $path): string
+    {
+        $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
+
+        if ($this->getStorageDiskName() === 'local_secure_attachments') {
+            return $path;
+        }
+
+        return 'uploads/files/' . $path;
+    }
+}
index 8d8da61ec185b42691fde6bd1673abeb44a307ad..038e6aa417c839b8f794038ec2bd40545f71e111 100644 (file)
@@ -33,9 +33,10 @@ class ImageService
         int $uploadedTo = 0,
         int $resizeWidth = null,
         int $resizeHeight = null,
-        bool $keepRatio = true
+        bool $keepRatio = true,
+        string $imageName = '',
     ): Image {
-        $imageName = $uploadedFile->getClientOriginalName();
+        $imageName = $imageName ?: $uploadedFile->getClientOriginalName();
         $imageData = file_get_contents($uploadedFile->getRealPath());
 
         if ($resizeWidth !== null || $resizeHeight !== null) {
@@ -133,6 +134,19 @@ class ImageService
         return $disk->get($image->path);
     }
 
+    /**
+     * Get the raw data content from an image.
+     *
+     * @throws Exception
+     * @returns ?resource
+     */
+    public function getImageStream(Image $image): mixed
+    {
+        $disk = $this->storage->getDisk();
+
+        return $disk->stream($image->path);
+    }
+
     /**
      * Destroy an image along with its revisions, thumbnails and remaining folders.
      *
@@ -140,11 +154,19 @@ class ImageService
      */
     public function destroy(Image $image): void
     {
-        $disk = $this->storage->getDisk($image->type);
-        $disk->destroyAllMatchingNameFromPath($image->path);
+        $this->destroyFileAtPath($image->type, $image->path);
         $image->delete();
     }
 
+    /**
+     * Destroy the underlying image file at the given path.
+     */
+    public function destroyFileAtPath(string $type, string $path): void
+    {
+        $disk = $this->storage->getDisk($type);
+        $disk->destroyAllMatchingNameFromPath($path);
+    }
+
     /**
      * Delete gallery and drawings that are not within HTML content of pages or page revisions.
      * Checks based off of only the image name.
index dc4abc0f281b6ba5fbbc6828411bd4abef40b3bb..ddaa26a9400c343b2dea6bf32dd118f1a5e79bfc 100644 (file)
@@ -110,10 +110,20 @@ class ImageStorage
     }
 
     /**
-     * Gets a public facing url for an image by checking relevant environment variables.
+     * Gets a public facing url for an image or location at the given path.
+     */
+    public static function getPublicUrl(string $filePath): string
+    {
+        return static::getPublicBaseUrl() . '/' . ltrim($filePath, '/');
+    }
+
+    /**
+     * Get the public base URL used for images.
+     * Will not include any path element of the image file, just the base part
+     * from where the path is then expected to start from.
      * If s3-style store is in use it will default to guessing a public bucket URL.
      */
-    public function getPublicUrl(string $filePath): string
+    protected static function getPublicBaseUrl(): string
     {
         $storageUrl = config('filesystems.url');
 
@@ -131,6 +141,6 @@ class ImageStorage
 
         $basePath = $storageUrl ?: url('/');
 
-        return rtrim($basePath, '/') . $filePath;
+        return rtrim($basePath, '/');
     }
 }
index 798b72abdbf9d9e0384b66c2508dccf89cf39530..8df702e0d94183b23248a441935e6723d199c4d1 100644 (file)
@@ -55,6 +55,15 @@ class ImageStorageDisk
         return $this->filesystem->get($this->adjustPathForDisk($path));
     }
 
+    /**
+     * Get a stream to the file at the given path.
+     * @returns ?resource
+     */
+    public function stream(string $path): mixed
+    {
+        return $this->filesystem->readStream($this->adjustPathForDisk($path));
+    }
+
     /**
      * Save the given image data at the given path. Can choose to set
      * the image as public which will update its visibility after saving.
index b8c53d43916294dccf63ed3821222f2888edf22d..7517955b9f60b2e14afbd91fddb7123fc9fb2095 100644 (file)
@@ -6,6 +6,7 @@ use DOMDocument;
 use DOMElement;
 use DOMNode;
 use DOMNodeList;
+use DOMText;
 use DOMXPath;
 
 /**
@@ -81,6 +82,14 @@ class HtmlDocument
         return $element;
     }
 
+    /**
+     * Create a new text node within this document.
+     */
+    public function createTextNode(string $text): DOMText
+    {
+        return $this->document->createTextNode($text);
+    }
+
     /**
      * Get an element within the document of the given ID.
      */
index 0a643e1142e46b4d6bbf2c474b08d64e3b0ae9dd..6fbfd98745b8bac922648e0354b9d55c9b9245a2 100755 (executable)
Binary files a/bookstack-system-cli and b/bookstack-system-cli differ
index 5c54774f1e274f355178d41b78b60fae33ec853f..b8d8da9e72ff0c732e3572165c91942fd040b5cb 100644 (file)
         "ext-json": "*",
         "ext-mbstring": "*",
         "ext-xml": "*",
+        "ext-zip": "*",
         "bacon/bacon-qr-code": "^3.0",
         "doctrine/dbal": "^3.5",
         "dompdf/dompdf": "^3.0",
         "guzzlehttp/guzzle": "^7.4",
         "intervention/image": "^3.5",
         "knplabs/knp-snappy": "^1.5",
-        "laravel/framework": "^10.10",
+        "laravel/framework": "^10.48.23",
         "laravel/socialite": "^5.10",
         "laravel/tinker": "^2.8",
         "league/commonmark": "^2.3",
index d86ec0b33d61d4fb179b9198e70efd4fe9f823d9..f744f76208c9981152467d485a821691a9fc5981 100644 (file)
@@ -4,20 +4,20 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "b24a1daf815b6910b51a2acc5e2d38e7",
+    "content-hash": "4a5a18010b7f4b32b7f0ae2a3e6305bb",
     "packages": [
         {
             "name": "aws/aws-crt-php",
-            "version": "v1.2.6",
+            "version": "v1.2.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/awslabs/aws-crt-php.git",
-                "reference": "a63485b65b6b3367039306496d49737cf1995408"
+                "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/a63485b65b6b3367039306496d49737cf1995408",
-                "reference": "a63485b65b6b3367039306496d49737cf1995408",
+                "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
+                "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/awslabs/aws-crt-php/issues",
-                "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.6"
+                "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
             },
-            "time": "2024-06-13T17:21:28+00:00"
+            "time": "2024-10-18T22:15:13+00:00"
         },
         {
             "name": "aws/aws-sdk-php",
-            "version": "3.322.6",
+            "version": "3.331.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/aws/aws-sdk-php.git",
-                "reference": "ae7b0edab466c3440fe007c07cb62ae32a4dbfca"
+                "reference": "0f8b3f63ba7b296afedcb3e6a43ce140831b9400"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ae7b0edab466c3440fe007c07cb62ae32a4dbfca",
-                "reference": "ae7b0edab466c3440fe007c07cb62ae32a4dbfca",
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0f8b3f63ba7b296afedcb3e6a43ce140831b9400",
+                "reference": "0f8b3f63ba7b296afedcb3e6a43ce140831b9400",
                 "shasum": ""
             },
             "require": {
                 "nette/neon": "^2.3",
                 "paragonie/random_compat": ">= 2",
                 "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5",
-                "psr/cache": "^1.0",
-                "psr/simple-cache": "^1.0",
+                "psr/cache": "^1.0 || ^2.0 || ^3.0",
+                "psr/simple-cache": "^1.0 || ^2.0 || ^3.0",
                 "sebastian/comparator": "^1.2.3 || ^4.0",
                 "yoast/phpunit-polyfills": "^1.0"
             },
             "support": {
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.322.6"
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.331.0"
             },
-            "time": "2024-09-26T18:12:45+00:00"
+            "time": "2024-11-27T19:12:58+00:00"
         },
         {
             "name": "bacon/bacon-qr-code",
-            "version": "v3.0.0",
+            "version": "v3.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Bacon/BaconQrCode.git",
-                "reference": "510de6eca6248d77d31b339d62437cc995e2fb41"
+                "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/510de6eca6248d77d31b339d62437cc995e2fb41",
-                "reference": "510de6eca6248d77d31b339d62437cc995e2fb41",
+                "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f",
+                "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f",
                 "shasum": ""
             },
             "require": {
             "homepage": "https://github.com/Bacon/BaconQrCode",
             "support": {
                 "issues": "https://github.com/Bacon/BaconQrCode/issues",
-                "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.0"
+                "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1"
             },
-            "time": "2024-04-18T11:16:25+00:00"
+            "time": "2024-10-01T13:55:55+00:00"
         },
         {
             "name": "brick/math",
         },
         {
             "name": "doctrine/dbal",
-            "version": "3.9.1",
+            "version": "3.9.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/dbal.git",
-                "reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7"
+                "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/dbal/zipball/d7dc08f98cba352b2bab5d32c5e58f7e745c11a7",
-                "reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7",
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba",
+                "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba",
                 "shasum": ""
             },
             "require": {
                 "doctrine/coding-standard": "12.0.0",
                 "fig/log-test": "^1",
                 "jetbrains/phpstorm-stubs": "2023.1",
-                "phpstan/phpstan": "1.12.0",
+                "phpstan/phpstan": "1.12.6",
                 "phpstan/phpstan-strict-rules": "^1.6",
                 "phpunit/phpunit": "9.6.20",
                 "psalm/plugin-phpunit": "0.18.4",
             ],
             "support": {
                 "issues": "https://github.com/doctrine/dbal/issues",
-                "source": "https://github.com/doctrine/dbal/tree/3.9.1"
+                "source": "https://github.com/doctrine/dbal/tree/3.9.3"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-01T13:49:23+00:00"
+            "time": "2024-10-10T17:56:43+00:00"
         },
         {
             "name": "doctrine/deprecations",
         },
         {
             "name": "dragonmantank/cron-expression",
-            "version": "v3.3.3",
+            "version": "v3.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/dragonmantank/cron-expression.git",
-                "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a"
+                "reference": "8c784d071debd117328803d86b2097615b457500"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/adfb1f505deb6384dc8b39804c5065dd3c8c8c0a",
-                "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a",
+                "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500",
+                "reference": "8c784d071debd117328803d86b2097615b457500",
                 "shasum": ""
             },
             "require": {
             "require-dev": {
                 "phpstan/extension-installer": "^1.0",
                 "phpstan/phpstan": "^1.0",
-                "phpstan/phpstan-webmozart-assert": "^1.0",
                 "phpunit/phpunit": "^7.0|^8.0|^9.0"
             },
             "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.x-dev"
+                }
+            },
             "autoload": {
                 "psr-4": {
                     "Cron\\": "src/Cron/"
             ],
             "support": {
                 "issues": "https://github.com/dragonmantank/cron-expression/issues",
-                "source": "https://github.com/dragonmantank/cron-expression/tree/v3.3.3"
+                "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2023-08-10T19:36:49+00:00"
+            "time": "2024-10-09T13:47:03+00:00"
         },
         {
             "name": "egulias/email-validator",
         },
         {
             "name": "firebase/php-jwt",
-            "version": "v6.10.1",
+            "version": "v6.10.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/firebase/php-jwt.git",
-                "reference": "500501c2ce893c824c801da135d02661199f60c5"
+                "reference": "30c19ed0f3264cb660ea496895cfb6ef7ee3653b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5",
-                "reference": "500501c2ce893c824c801da135d02661199f60c5",
+                "url": "https://api.github.com/repos/firebase/php-jwt/zipball/30c19ed0f3264cb660ea496895cfb6ef7ee3653b",
+                "reference": "30c19ed0f3264cb660ea496895cfb6ef7ee3653b",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/firebase/php-jwt/issues",
-                "source": "https://github.com/firebase/php-jwt/tree/v6.10.1"
+                "source": "https://github.com/firebase/php-jwt/tree/v6.10.2"
             },
-            "time": "2024-05-18T18:05:11+00:00"
+            "time": "2024-11-24T11:22:49+00:00"
         },
         {
             "name": "fruitcake/php-cors",
         },
         {
             "name": "guzzlehttp/promises",
-            "version": "2.0.3",
+            "version": "2.0.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/promises.git",
-                "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8"
+                "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/promises/zipball/6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8",
-                "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8",
+                "url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455",
+                "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/guzzle/promises/issues",
-                "source": "https://github.com/guzzle/promises/tree/2.0.3"
+                "source": "https://github.com/guzzle/promises/tree/2.0.4"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-07-18T10:29:17+00:00"
+            "time": "2024-10-17T10:06:22+00:00"
         },
         {
             "name": "guzzlehttp/psr7",
         },
         {
             "name": "intervention/image",
-            "version": "3.8.0",
+            "version": "3.9.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Intervention/image.git",
-                "reference": "1786ad5e1789050939d73cd195de4b8eaeeb34ed"
+                "reference": "b496d1f6b9f812f96166623358dfcafb8c3b1683"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Intervention/image/zipball/1786ad5e1789050939d73cd195de4b8eaeeb34ed",
-                "reference": "1786ad5e1789050939d73cd195de4b8eaeeb34ed",
+                "url": "https://api.github.com/repos/Intervention/image/zipball/b496d1f6b9f812f96166623358dfcafb8c3b1683",
+                "reference": "b496d1f6b9f812f96166623358dfcafb8c3b1683",
                 "shasum": ""
             },
             "require": {
                 "ext-mbstring": "*",
-                "intervention/gif": "^4.1",
+                "intervention/gif": "^4.2",
                 "php": "^8.1"
             },
             "require-dev": {
             ],
             "support": {
                 "issues": "https://github.com/Intervention/image/issues",
-                "source": "https://github.com/Intervention/image/tree/3.8.0"
+                "source": "https://github.com/Intervention/image/tree/3.9.1"
             },
             "funding": [
                 {
                     "type": "ko_fi"
                 }
             ],
-            "time": "2024-08-16T14:57:26+00:00"
+            "time": "2024-10-27T10:15:54+00:00"
         },
         {
             "name": "knplabs/knp-snappy",
         },
         {
             "name": "laravel/framework",
-            "version": "v10.48.22",
+            "version": "v10.48.25",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/framework.git",
-                "reference": "c4ea52bb044faef4a103d7dd81746c01b2ec860e"
+                "reference": "f132b23b13909cc22c615c01b0c5640541c3da0c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/framework/zipball/c4ea52bb044faef4a103d7dd81746c01b2ec860e",
-                "reference": "c4ea52bb044faef4a103d7dd81746c01b2ec860e",
+                "url": "https://api.github.com/repos/laravel/framework/zipball/f132b23b13909cc22c615c01b0c5640541c3da0c",
+                "reference": "f132b23b13909cc22c615c01b0c5640541c3da0c",
                 "shasum": ""
             },
             "require": {
                 "nyholm/psr7": "^1.2",
                 "orchestra/testbench-core": "^8.23.4",
                 "pda/pheanstalk": "^4.0",
-                "phpstan/phpstan": "^1.4.7",
+                "phpstan/phpstan": "~1.11.11",
                 "phpunit/phpunit": "^10.0.7",
                 "predis/predis": "^2.0.2",
                 "symfony/cache": "^6.2",
                 "issues": "https://github.com/laravel/framework/issues",
                 "source": "https://github.com/laravel/framework"
             },
-            "time": "2024-09-12T15:00:09+00:00"
+            "time": "2024-11-26T15:32:57+00:00"
         },
         {
             "name": "laravel/prompts",
         },
         {
             "name": "laravel/serializable-closure",
-            "version": "v1.3.5",
+            "version": "v1.3.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/serializable-closure.git",
-                "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c"
+                "reference": "4f48ade902b94323ca3be7646db16209ec76be3d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c",
-                "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c",
+                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/4f48ade902b94323ca3be7646db16209ec76be3d",
+                "reference": "4f48ade902b94323ca3be7646db16209ec76be3d",
                 "shasum": ""
             },
             "require": {
                 "issues": "https://github.com/laravel/serializable-closure/issues",
                 "source": "https://github.com/laravel/serializable-closure"
             },
-            "time": "2024-09-23T13:33:08+00:00"
+            "time": "2024-11-14T18:34:49+00:00"
         },
         {
             "name": "laravel/socialite",
         },
         {
             "name": "league/flysystem",
-            "version": "3.28.0",
+            "version": "3.29.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/flysystem.git",
-                "reference": "e611adab2b1ae2e3072fa72d62c62f52c2bf1f0c"
+                "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/e611adab2b1ae2e3072fa72d62c62f52c2bf1f0c",
-                "reference": "e611adab2b1ae2e3072fa72d62c62f52c2bf1f0c",
+                "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/edc1bb7c86fab0776c3287dbd19b5fa278347319",
+                "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/thephpleague/flysystem/issues",
-                "source": "https://github.com/thephpleague/flysystem/tree/3.28.0"
+                "source": "https://github.com/thephpleague/flysystem/tree/3.29.1"
             },
-            "time": "2024-05-22T10:09:12+00:00"
+            "time": "2024-10-08T08:58:34+00:00"
         },
         {
             "name": "league/flysystem-aws-s3-v3",
-            "version": "3.28.0",
+            "version": "3.29.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
-                "reference": "22071ef1604bc776f5ff2468ac27a752514665c8"
+                "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/22071ef1604bc776f5ff2468ac27a752514665c8",
-                "reference": "22071ef1604bc776f5ff2468ac27a752514665c8",
+                "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/c6ff6d4606e48249b63f269eba7fabdb584e76a9",
+                "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9",
                 "shasum": ""
             },
             "require": {
                 "storage"
             ],
             "support": {
-                "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.28.0"
+                "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.29.0"
             },
-            "time": "2024-05-06T20:05:52+00:00"
+            "time": "2024-08-17T13:10:48+00:00"
         },
         {
             "name": "league/flysystem-local",
-            "version": "3.28.0",
+            "version": "3.29.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/flysystem-local.git",
-                "reference": "13f22ea8be526ea58c2ddff9e158ef7c296e4f40"
+                "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/13f22ea8be526ea58c2ddff9e158ef7c296e4f40",
-                "reference": "13f22ea8be526ea58c2ddff9e158ef7c296e4f40",
+                "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/e0e8d52ce4b2ed154148453d321e97c8e931bd27",
+                "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27",
                 "shasum": ""
             },
             "require": {
                 "local"
             ],
             "support": {
-                "source": "https://github.com/thephpleague/flysystem-local/tree/3.28.0"
+                "source": "https://github.com/thephpleague/flysystem-local/tree/3.29.0"
             },
-            "time": "2024-05-06T20:05:52+00:00"
+            "time": "2024-08-09T21:24:39+00:00"
         },
         {
             "name": "league/html-to-markdown",
         },
         {
             "name": "monolog/monolog",
-            "version": "3.7.0",
+            "version": "3.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/monolog.git",
-                "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8"
+                "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f4393b648b78a5408747de94fca38beb5f7e9ef8",
-                "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8",
+                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/32e515fdc02cdafbe4593e30a9350d486b125b67",
+                "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67",
                 "shasum": ""
             },
             "require": {
                 "guzzlehttp/psr7": "^2.2",
                 "mongodb/mongodb": "^1.8",
                 "php-amqplib/php-amqplib": "~2.4 || ^3",
-                "phpstan/phpstan": "^1.9",
-                "phpstan/phpstan-deprecation-rules": "^1.0",
-                "phpstan/phpstan-strict-rules": "^1.4",
-                "phpunit/phpunit": "^10.5.17",
+                "php-console/php-console": "^3.1.8",
+                "phpstan/phpstan": "^2",
+                "phpstan/phpstan-deprecation-rules": "^2",
+                "phpstan/phpstan-strict-rules": "^2",
+                "phpunit/phpunit": "^10.5.17 || ^11.0.7",
                 "predis/predis": "^1.1 || ^2",
-                "ruflin/elastica": "^7",
+                "rollbar/rollbar": "^4.0",
+                "ruflin/elastica": "^7 || ^8",
                 "symfony/mailer": "^5.4 || ^6",
                 "symfony/mime": "^5.4 || ^6"
             },
             ],
             "support": {
                 "issues": "https://github.com/Seldaek/monolog/issues",
-                "source": "https://github.com/Seldaek/monolog/tree/3.7.0"
+                "source": "https://github.com/Seldaek/monolog/tree/3.8.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-06-28T09:40:51+00:00"
+            "time": "2024-11-12T13:57:08+00:00"
         },
         {
             "name": "mtdowling/jmespath.php",
         },
         {
             "name": "nette/schema",
-            "version": "v1.3.0",
+            "version": "v1.3.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nette/schema.git",
-                "reference": "a6d3a6d1f545f01ef38e60f375d1cf1f4de98188"
+                "reference": "da801d52f0354f70a638673c4a0f04e16529431d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nette/schema/zipball/a6d3a6d1f545f01ef38e60f375d1cf1f4de98188",
-                "reference": "a6d3a6d1f545f01ef38e60f375d1cf1f4de98188",
+                "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d",
+                "reference": "da801d52f0354f70a638673c4a0f04e16529431d",
                 "shasum": ""
             },
             "require": {
                 "nette/utils": "^4.0",
-                "php": "8.1 - 8.3"
+                "php": "8.1 - 8.4"
             },
             "require-dev": {
-                "nette/tester": "^2.4",
+                "nette/tester": "^2.5.2",
                 "phpstan/phpstan-nette": "^1.0",
                 "tracy/tracy": "^2.8"
             },
             ],
             "support": {
                 "issues": "https://github.com/nette/schema/issues",
-                "source": "https://github.com/nette/schema/tree/v1.3.0"
+                "source": "https://github.com/nette/schema/tree/v1.3.2"
             },
-            "time": "2023-12-11T11:54:22+00:00"
+            "time": "2024-10-06T23:10:23+00:00"
         },
         {
             "name": "nette/utils",
         },
         {
             "name": "nikic/php-parser",
-            "version": "v5.2.0",
+            "version": "v5.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb"
+                "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb",
-                "reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b",
+                "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v5.2.0"
+                "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1"
             },
-            "time": "2024-09-15T16:40:33+00:00"
+            "time": "2024-10-08T18:51:32+00:00"
         },
         {
             "name": "nunomaduro/termwind",
-            "version": "v1.15.1",
+            "version": "v1.17.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nunomaduro/termwind.git",
-                "reference": "8ab0b32c8caa4a2e09700ea32925441385e4a5dc"
+                "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/8ab0b32c8caa4a2e09700ea32925441385e4a5dc",
-                "reference": "8ab0b32c8caa4a2e09700ea32925441385e4a5dc",
+                "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/5369ef84d8142c1d87e4ec278711d4ece3cbf301",
+                "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301",
                 "shasum": ""
             },
             "require": {
                 "ext-mbstring": "*",
-                "php": "^8.0",
-                "symfony/console": "^5.3.0|^6.0.0"
+                "php": "^8.1",
+                "symfony/console": "^6.4.15"
             },
             "require-dev": {
-                "ergebnis/phpstan-rules": "^1.0.",
-                "illuminate/console": "^8.0|^9.0",
-                "illuminate/support": "^8.0|^9.0",
-                "laravel/pint": "^1.0.0",
-                "pestphp/pest": "^1.21.0",
-                "pestphp/pest-plugin-mock": "^1.0",
-                "phpstan/phpstan": "^1.4.6",
-                "phpstan/phpstan-strict-rules": "^1.1.0",
-                "symfony/var-dumper": "^5.2.7|^6.0.0",
+                "illuminate/console": "^10.48.24",
+                "illuminate/support": "^10.48.24",
+                "laravel/pint": "^1.18.2",
+                "pestphp/pest": "^2.36.0",
+                "pestphp/pest-plugin-mock": "2.0.0",
+                "phpstan/phpstan": "^1.12.11",
+                "phpstan/phpstan-strict-rules": "^1.6.1",
+                "symfony/var-dumper": "^6.4.15",
                 "thecodingmachine/phpstan-strict-rules": "^1.0.0"
             },
             "type": "library",
             ],
             "support": {
                 "issues": "https://github.com/nunomaduro/termwind/issues",
-                "source": "https://github.com/nunomaduro/termwind/tree/v1.15.1"
+                "source": "https://github.com/nunomaduro/termwind/tree/v1.17.0"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2023-02-08T01:06:31+00:00"
+            "time": "2024-11-21T10:36:35+00:00"
         },
         {
             "name": "onelogin/php-saml",
         },
         {
             "name": "predis/predis",
-            "version": "v2.2.2",
+            "version": "v2.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/predis/predis.git",
-                "reference": "b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1"
+                "reference": "bac46bfdb78cd6e9c7926c697012aae740cb9ec9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/predis/predis/zipball/b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1",
-                "reference": "b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1",
+                "url": "https://api.github.com/repos/predis/predis/zipball/bac46bfdb78cd6e9c7926c697012aae740cb9ec9",
+                "reference": "bac46bfdb78cd6e9c7926c697012aae740cb9ec9",
                 "shasum": ""
             },
             "require": {
             "require-dev": {
                 "friendsofphp/php-cs-fixer": "^3.3",
                 "phpstan/phpstan": "^1.9",
-                "phpunit/phpunit": "^8.0 || ~9.4.4"
+                "phpunit/phpunit": "^8.0 || ^9.4"
             },
             "suggest": {
                 "ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
             ],
             "support": {
                 "issues": "https://github.com/predis/predis/issues",
-                "source": "https://github.com/predis/predis/tree/v2.2.2"
+                "source": "https://github.com/predis/predis/tree/v2.3.0"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2023-09-13T16:42:03+00:00"
+            "time": "2024-11-21T20:00:02+00:00"
         },
         {
             "name": "psr/cache",
         },
         {
             "name": "robrichards/xmlseclibs",
-            "version": "3.1.1",
+            "version": "3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/robrichards/xmlseclibs.git",
-                "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df"
+                "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/f8f19e58f26cdb42c54b214ff8a820760292f8df",
-                "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df",
+                "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/2bdfd742624d739dfadbd415f00181b4a77aaf07",
+                "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/robrichards/xmlseclibs/issues",
-                "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.1"
+                "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.3"
             },
-            "time": "2020-09-05T13:00:25+00:00"
+            "time": "2024-11-20T21:13:56+00:00"
         },
         {
             "name": "sabberworm/php-css-parser",
-            "version": "v8.6.0",
+            "version": "v8.7.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
-                "reference": "d2fb94a9641be84d79c7548c6d39bbebba6e9a70"
+                "reference": "f414ff953002a9b18e3a116f5e462c56f21237cf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d2fb94a9641be84d79c7548c6d39bbebba6e9a70",
-                "reference": "d2fb94a9641be84d79c7548c6d39bbebba6e9a70",
+                "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f414ff953002a9b18e3a116f5e462c56f21237cf",
+                "reference": "f414ff953002a9b18e3a116f5e462c56f21237cf",
                 "shasum": ""
             },
             "require": {
                 "ext-iconv": "*",
-                "php": ">=5.6.20"
+                "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^5.7.27"
+                "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.40"
             },
             "suggest": {
                 "ext-mbstring": "for parsing UTF-8 CSS"
             ],
             "support": {
                 "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
-                "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.6.0"
+                "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.7.0"
             },
-            "time": "2024-07-01T07:33:21+00:00"
+            "time": "2024-10-27T17:38:32+00:00"
         },
         {
             "name": "socialiteproviders/discord",
         },
         {
             "name": "socialiteproviders/manager",
-            "version": "v4.6.0",
+            "version": "v4.7.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/Manager.git",
-                "reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21"
+                "reference": "ab0691b82cec77efd90154c78f1854903455c82f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/dea5190981c31b89e52259da9ab1ca4e2b258b21",
-                "reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21",
+                "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/ab0691b82cec77efd90154c78f1854903455c82f",
+                "reference": "ab0691b82cec77efd90154c78f1854903455c82f",
                 "shasum": ""
             },
             "require": {
                 "issues": "https://github.com/socialiteproviders/manager/issues",
                 "source": "https://github.com/socialiteproviders/manager"
             },
-            "time": "2024-05-04T07:57:39+00:00"
+            "time": "2024-11-10T01:56:18+00:00"
         },
         {
             "name": "socialiteproviders/microsoft-azure",
         },
         {
             "name": "symfony/console",
-            "version": "v6.4.12",
+            "version": "v6.4.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765"
+                "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/72d080eb9edf80e36c19be61f72c98ed8273b765",
-                "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765",
+                "url": "https://api.github.com/repos/symfony/console/zipball/f1fc6f47283e27336e7cebb9e8946c8de7bff9bd",
+                "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd",
                 "shasum": ""
             },
             "require": {
                 "terminal"
             ],
             "support": {
-                "source": "https://github.com/symfony/console/tree/v6.4.12"
+                "source": "https://github.com/symfony/console/tree/v6.4.15"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-20T08:15:52+00:00"
+            "time": "2024-11-06T14:19:14+00:00"
         },
         {
             "name": "symfony/css-selector",
-            "version": "v6.4.8",
+            "version": "v6.4.13",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/css-selector.git",
-                "reference": "4b61b02fe15db48e3687ce1c45ea385d1780fe08"
+                "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/css-selector/zipball/4b61b02fe15db48e3687ce1c45ea385d1780fe08",
-                "reference": "4b61b02fe15db48e3687ce1c45ea385d1780fe08",
+                "url": "https://api.github.com/repos/symfony/css-selector/zipball/cb23e97813c5837a041b73a6d63a9ddff0778f5e",
+                "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e",
                 "shasum": ""
             },
             "require": {
             "description": "Converts CSS selectors to XPath expressions",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/css-selector/tree/v6.4.8"
+                "source": "https://github.com/symfony/css-selector/tree/v6.4.13"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-05-31T14:49:08+00:00"
+            "time": "2024-09-25T14:18:03+00:00"
         },
         {
             "name": "symfony/deprecation-contracts",
-            "version": "v3.5.0",
+            "version": "v3.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/deprecation-contracts.git",
-                "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1"
+                "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1",
-                "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1",
+                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
+                "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
                 "shasum": ""
             },
             "require": {
             "description": "A generic function and convention to trigger deprecation notices",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0"
+                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-04-18T09:32:20+00:00"
+            "time": "2024-09-25T14:20:29+00:00"
         },
         {
             "name": "symfony/error-handler",
-            "version": "v6.4.10",
+            "version": "v6.4.14",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/error-handler.git",
-                "reference": "231f1b2ee80f72daa1972f7340297d67439224f0"
+                "reference": "9e024324511eeb00983ee76b9aedc3e6ecd993d9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/error-handler/zipball/231f1b2ee80f72daa1972f7340297d67439224f0",
-                "reference": "231f1b2ee80f72daa1972f7340297d67439224f0",
+                "url": "https://api.github.com/repos/symfony/error-handler/zipball/9e024324511eeb00983ee76b9aedc3e6ecd993d9",
+                "reference": "9e024324511eeb00983ee76b9aedc3e6ecd993d9",
                 "shasum": ""
             },
             "require": {
             "description": "Provides tools to manage errors and ease debugging PHP code",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/error-handler/tree/v6.4.10"
+                "source": "https://github.com/symfony/error-handler/tree/v6.4.14"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-07-26T12:30:32+00:00"
+            "time": "2024-11-05T15:34:40+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v6.4.8",
+            "version": "v6.4.13",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "8d7507f02b06e06815e56bb39aa0128e3806208b"
+                "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/8d7507f02b06e06815e56bb39aa0128e3806208b",
-                "reference": "8d7507f02b06e06815e56bb39aa0128e3806208b",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e",
+                "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e",
                 "shasum": ""
             },
             "require": {
             "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.8"
+                "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.13"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-05-31T14:49:08+00:00"
+            "time": "2024-09-25T14:18:03+00:00"
         },
         {
             "name": "symfony/event-dispatcher-contracts",
-            "version": "v3.5.0",
+            "version": "v3.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher-contracts.git",
-                "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50"
+                "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50",
-                "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f",
+                "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f",
                 "shasum": ""
             },
             "require": {
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0"
+                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-04-18T09:32:20+00:00"
+            "time": "2024-09-25T14:20:29+00:00"
         },
         {
             "name": "symfony/finder",
-            "version": "v6.4.11",
+            "version": "v6.4.13",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/finder.git",
-                "reference": "d7eb6daf8cd7e9ac4976e9576b32042ef7253453"
+                "reference": "daea9eca0b08d0ed1dc9ab702a46128fd1be4958"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/d7eb6daf8cd7e9ac4976e9576b32042ef7253453",
-                "reference": "d7eb6daf8cd7e9ac4976e9576b32042ef7253453",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/daea9eca0b08d0ed1dc9ab702a46128fd1be4958",
+                "reference": "daea9eca0b08d0ed1dc9ab702a46128fd1be4958",
                 "shasum": ""
             },
             "require": {
             "description": "Finds files and directories via an intuitive fluent interface",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/finder/tree/v6.4.11"
+                "source": "https://github.com/symfony/finder/tree/v6.4.13"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-08-13T14:27:37+00:00"
+            "time": "2024-10-01T08:30:56+00:00"
         },
         {
             "name": "symfony/http-foundation",
-            "version": "v6.4.12",
+            "version": "v6.4.16",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-foundation.git",
-                "reference": "133ac043875f59c26c55e79cf074562127cce4d2"
+                "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/133ac043875f59c26c55e79cf074562127cce4d2",
-                "reference": "133ac043875f59c26c55e79cf074562127cce4d2",
+                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/431771b7a6f662f1575b3cfc8fd7617aa9864d57",
+                "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57",
                 "shasum": ""
             },
             "require": {
                 "symfony/polyfill-php83": "^1.27"
             },
             "conflict": {
-                "symfony/cache": "<6.3"
+                "symfony/cache": "<6.4.12|>=7.0,<7.1.5"
             },
             "require-dev": {
                 "doctrine/dbal": "^2.13.1|^3|^4",
                 "predis/predis": "^1.1|^2.0",
-                "symfony/cache": "^6.3|^7.0",
+                "symfony/cache": "^6.4.12|^7.1.5",
                 "symfony/dependency-injection": "^5.4|^6.0|^7.0",
                 "symfony/expression-language": "^5.4|^6.0|^7.0",
                 "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0",
             "description": "Defines an object-oriented layer for the HTTP specification",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-foundation/tree/v6.4.12"
+                "source": "https://github.com/symfony/http-foundation/tree/v6.4.16"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-20T08:18:25+00:00"
+            "time": "2024-11-13T18:58:10+00:00"
         },
         {
             "name": "symfony/http-kernel",
-            "version": "v6.4.12",
+            "version": "v6.4.16",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-kernel.git",
-                "reference": "96df83d51b5f78804f70c093b97310794fd6257b"
+                "reference": "8838b5b21d807923b893ccbfc2cbeda0f1bc00f0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/96df83d51b5f78804f70c093b97310794fd6257b",
-                "reference": "96df83d51b5f78804f70c093b97310794fd6257b",
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/8838b5b21d807923b893ccbfc2cbeda0f1bc00f0",
+                "reference": "8838b5b21d807923b893ccbfc2cbeda0f1bc00f0",
                 "shasum": ""
             },
             "require": {
             "description": "Provides a structured process for converting a Request into a Response",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-kernel/tree/v6.4.12"
+                "source": "https://github.com/symfony/http-kernel/tree/v6.4.16"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-21T06:02:57+00:00"
+            "time": "2024-11-27T12:49:36+00:00"
         },
         {
             "name": "symfony/mime",
-            "version": "v6.4.12",
+            "version": "v6.4.13",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/mime.git",
-                "reference": "abe16ee7790b16aa525877419deb0f113953f0e1"
+                "reference": "1de1cf14d99b12c7ebbb850491ec6ae3ed468855"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/mime/zipball/abe16ee7790b16aa525877419deb0f113953f0e1",
-                "reference": "abe16ee7790b16aa525877419deb0f113953f0e1",
+                "url": "https://api.github.com/repos/symfony/mime/zipball/1de1cf14d99b12c7ebbb850491ec6ae3ed468855",
+                "reference": "1de1cf14d99b12c7ebbb850491ec6ae3ed468855",
                 "shasum": ""
             },
             "require": {
                 "mime-type"
             ],
             "support": {
-                "source": "https://github.com/symfony/mime/tree/v6.4.12"
+                "source": "https://github.com/symfony/mime/tree/v6.4.13"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-20T08:18:25+00:00"
+            "time": "2024-10-25T15:07:50+00:00"
         },
         {
             "name": "symfony/polyfill-ctype",
         },
         {
             "name": "symfony/process",
-            "version": "v6.4.12",
+            "version": "v6.4.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "3f94e5f13ff58df371a7ead461b6e8068900fbb3"
+                "reference": "3cb242f059c14ae08591c5c4087d1fe443564392"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/3f94e5f13ff58df371a7ead461b6e8068900fbb3",
-                "reference": "3f94e5f13ff58df371a7ead461b6e8068900fbb3",
+                "url": "https://api.github.com/repos/symfony/process/zipball/3cb242f059c14ae08591c5c4087d1fe443564392",
+                "reference": "3cb242f059c14ae08591c5c4087d1fe443564392",
                 "shasum": ""
             },
             "require": {
             "description": "Executes commands in sub-processes",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/process/tree/v6.4.12"
+                "source": "https://github.com/symfony/process/tree/v6.4.15"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-17T12:47:12+00:00"
+            "time": "2024-11-06T14:19:14+00:00"
         },
         {
             "name": "symfony/routing",
-            "version": "v6.4.12",
+            "version": "v6.4.16",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/routing.git",
-                "reference": "a7c8036bd159486228dc9be3e846a00a0dda9f9f"
+                "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/routing/zipball/a7c8036bd159486228dc9be3e846a00a0dda9f9f",
-                "reference": "a7c8036bd159486228dc9be3e846a00a0dda9f9f",
+                "url": "https://api.github.com/repos/symfony/routing/zipball/91e02e606b4b705c2f4fb42f7e7708b7923a3220",
+                "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220",
                 "shasum": ""
             },
             "require": {
                 "url"
             ],
             "support": {
-                "source": "https://github.com/symfony/routing/tree/v6.4.12"
+                "source": "https://github.com/symfony/routing/tree/v6.4.16"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-20T08:32:26+00:00"
+            "time": "2024-11-13T15:31:34+00:00"
         },
         {
             "name": "symfony/service-contracts",
-            "version": "v3.5.0",
+            "version": "v3.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/service-contracts.git",
-                "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f"
+                "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f",
-                "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0",
+                "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0",
                 "shasum": ""
             },
             "require": {
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/service-contracts/tree/v3.5.0"
+                "source": "https://github.com/symfony/service-contracts/tree/v3.5.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-04-18T09:32:20+00:00"
+            "time": "2024-09-25T14:20:29+00:00"
         },
         {
             "name": "symfony/string",
-            "version": "v6.4.12",
+            "version": "v6.4.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/string.git",
-                "reference": "f8a1ccebd0997e16112dfecfd74220b78e5b284b"
+                "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/string/zipball/f8a1ccebd0997e16112dfecfd74220b78e5b284b",
-                "reference": "f8a1ccebd0997e16112dfecfd74220b78e5b284b",
+                "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f",
+                "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f",
                 "shasum": ""
             },
             "require": {
                 "utf8"
             ],
             "support": {
-                "source": "https://github.com/symfony/string/tree/v6.4.12"
+                "source": "https://github.com/symfony/string/tree/v6.4.15"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-20T08:15:52+00:00"
+            "time": "2024-11-13T13:31:12+00:00"
         },
         {
             "name": "symfony/translation",
-            "version": "v6.4.12",
+            "version": "v6.4.13",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "cf8360b8352b086be620fae8342c4d96e391a489"
+                "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/cf8360b8352b086be620fae8342c4d96e391a489",
-                "reference": "cf8360b8352b086be620fae8342c4d96e391a489",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/bee9bfabfa8b4045a66bf82520e492cddbaffa66",
+                "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66",
                 "shasum": ""
             },
             "require": {
             "description": "Provides tools to internationalize your application",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/translation/tree/v6.4.12"
+                "source": "https://github.com/symfony/translation/tree/v6.4.13"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-16T06:02:54+00:00"
+            "time": "2024-09-27T18:14:25+00:00"
         },
         {
             "name": "symfony/translation-contracts",
-            "version": "v3.5.0",
+            "version": "v3.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation-contracts.git",
-                "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a"
+                "reference": "4667ff3bd513750603a09c8dedbea942487fb07c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/b9d2189887bb6b2e0367a9fc7136c5239ab9b05a",
-                "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a",
+                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c",
+                "reference": "4667ff3bd513750603a09c8dedbea942487fb07c",
                 "shasum": ""
             },
             "require": {
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/translation-contracts/tree/v3.5.0"
+                "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-04-18T09:32:20+00:00"
+            "time": "2024-09-25T14:20:29+00:00"
         },
         {
             "name": "symfony/uid",
-            "version": "v6.4.12",
+            "version": "v6.4.13",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/uid.git",
-                "reference": "2f16054e0a9b194b8ca581d4a64eee3f7d4a9d4d"
+                "reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/uid/zipball/2f16054e0a9b194b8ca581d4a64eee3f7d4a9d4d",
-                "reference": "2f16054e0a9b194b8ca581d4a64eee3f7d4a9d4d",
+                "url": "https://api.github.com/repos/symfony/uid/zipball/18eb207f0436a993fffbdd811b5b8fa35fa5e007",
+                "reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007",
                 "shasum": ""
             },
             "require": {
                 "uuid"
             ],
             "support": {
-                "source": "https://github.com/symfony/uid/tree/v6.4.12"
+                "source": "https://github.com/symfony/uid/tree/v6.4.13"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-20T08:32:26+00:00"
+            "time": "2024-09-25T14:18:03+00:00"
         },
         {
             "name": "symfony/var-dumper",
-            "version": "v6.4.11",
+            "version": "v6.4.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/var-dumper.git",
-                "reference": "ee14c8254a480913268b1e3b1cba8045ed122694"
+                "reference": "38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/ee14c8254a480913268b1e3b1cba8045ed122694",
-                "reference": "ee14c8254a480913268b1e3b1cba8045ed122694",
+                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80",
+                "reference": "38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80",
                 "shasum": ""
             },
             "require": {
                 "dump"
             ],
             "support": {
-                "source": "https://github.com/symfony/var-dumper/tree/v6.4.11"
+                "source": "https://github.com/symfony/var-dumper/tree/v6.4.15"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-08-30T16:03:21+00:00"
+            "time": "2024-11-08T15:28:48+00:00"
         },
         {
             "name": "tijsverkoyen/css-to-inline-styles",
         },
         {
             "name": "voku/portable-ascii",
-            "version": "2.0.1",
+            "version": "2.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/voku/portable-ascii.git",
-                "reference": "b56450eed252f6801410d810c8e1727224ae0743"
+                "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b56450eed252f6801410d810c8e1727224ae0743",
-                "reference": "b56450eed252f6801410d810c8e1727224ae0743",
+                "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
+                "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
                 "shasum": ""
             },
             "require": {
             "authors": [
                 {
                     "name": "Lars Moelleken",
-                    "homepage": "http://www.moelleken.org/"
+                    "homepage": "https://www.moelleken.org/"
                 }
             ],
             "description": "Portable ASCII library - performance optimized (ascii) string functions for php.",
             ],
             "support": {
                 "issues": "https://github.com/voku/portable-ascii/issues",
-                "source": "https://github.com/voku/portable-ascii/tree/2.0.1"
+                "source": "https://github.com/voku/portable-ascii/tree/2.0.3"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-03-08T17:03:00+00:00"
+            "time": "2024-11-21T01:49:47+00:00"
         },
         {
             "name": "webmozart/assert",
     "packages-dev": [
         {
             "name": "fakerphp/faker",
-            "version": "v1.23.1",
+            "version": "v1.24.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/FakerPHP/Faker.git",
-                "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b"
+                "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/bfb4fe148adbf78eff521199619b93a52ae3554b",
-                "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b",
+                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
+                "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/FakerPHP/Faker/issues",
-                "source": "https://github.com/FakerPHP/Faker/tree/v1.23.1"
+                "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1"
             },
-            "time": "2024-01-02T13:46:09+00:00"
+            "time": "2024-11-21T13:46:39+00:00"
         },
         {
             "name": "filp/whoops",
         },
         {
             "name": "itsgoingd/clockwork",
-            "version": "v5.2.2",
+            "version": "v5.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/itsgoingd/clockwork.git",
-                "reference": "29bc4cedfbe742b419544c30b7b6e15cd9da08ef"
+                "reference": "7b0c40418df761f7a78e88762a323386a139d83d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/29bc4cedfbe742b419544c30b7b6e15cd9da08ef",
-                "reference": "29bc4cedfbe742b419544c30b7b6e15cd9da08ef",
+                "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/7b0c40418df761f7a78e88762a323386a139d83d",
+                "reference": "7b0c40418df761f7a78e88762a323386a139d83d",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "php": ">=5.6"
+                "php": ">=7.1"
             },
             "suggest": {
                 "ext-pdo": "Needed in order to use a SQL database for metadata storage",
                 "ext-pdo_mysql": "Needed in order to use MySQL for metadata storage",
                 "ext-pdo_postgres": "Needed in order to use Postgres for metadata storage",
                 "ext-pdo_sqlite": "Needed in order to use a SQLite for metadata storage",
-                "ext-redis": "Needed in order to use Redis for metadata storage"
+                "ext-redis": "Needed in order to use Redis for metadata storage",
+                "php-http/discovery": "Vanilla integration - required for the middleware zero-configuration setup"
             },
             "type": "library",
             "extra": {
             ],
             "support": {
                 "issues": "https://github.com/itsgoingd/clockwork/issues",
-                "source": "https://github.com/itsgoingd/clockwork/tree/v5.2.2"
+                "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.1"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2024-04-14T10:49:22+00:00"
+            "time": "2024-11-19T17:25:22+00:00"
         },
         {
             "name": "larastan/larastan",
-            "version": "v2.9.8",
+            "version": "v2.9.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/larastan/larastan.git",
-                "reference": "340badd89b0eb5bddbc503a4829c08cf9a2819d7"
+                "reference": "19012b39fbe4dede43dbe0c126d9681827a5e908"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/larastan/larastan/zipball/340badd89b0eb5bddbc503a4829c08cf9a2819d7",
-                "reference": "340badd89b0eb5bddbc503a4829c08cf9a2819d7",
+                "url": "https://api.github.com/repos/larastan/larastan/zipball/19012b39fbe4dede43dbe0c126d9681827a5e908",
+                "reference": "19012b39fbe4dede43dbe0c126d9681827a5e908",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "illuminate/console": "^9.52.16 || ^10.28.0 || ^11.0",
-                "illuminate/container": "^9.52.16 || ^10.28.0 || ^11.0",
-                "illuminate/contracts": "^9.52.16 || ^10.28.0 || ^11.0",
-                "illuminate/database": "^9.52.16 || ^10.28.0 || ^11.0",
-                "illuminate/http": "^9.52.16 || ^10.28.0 || ^11.0",
-                "illuminate/pipeline": "^9.52.16 || ^10.28.0 || ^11.0",
-                "illuminate/support": "^9.52.16 || ^10.28.0 || ^11.0",
+                "illuminate/console": "^9.52.16 || ^10.28.0 || ^11.16",
+                "illuminate/container": "^9.52.16 || ^10.28.0 || ^11.16",
+                "illuminate/contracts": "^9.52.16 || ^10.28.0 || ^11.16",
+                "illuminate/database": "^9.52.16 || ^10.28.0 || ^11.16",
+                "illuminate/http": "^9.52.16 || ^10.28.0 || ^11.16",
+                "illuminate/pipeline": "^9.52.16 || ^10.28.0 || ^11.16",
+                "illuminate/support": "^9.52.16 || ^10.28.0 || ^11.16",
                 "php": "^8.0.2",
                 "phpmyadmin/sql-parser": "^5.9.0",
-                "phpstan/phpstan": "^1.11.2"
+                "phpstan/phpstan": "^1.12.11"
             },
             "require-dev": {
                 "doctrine/coding-standard": "^12.0",
+                "laravel/framework": "^9.52.16 || ^10.28.0 || ^11.16",
+                "mockery/mockery": "^1.5.1",
                 "nikic/php-parser": "^4.19.1",
                 "orchestra/canvas": "^7.11.1 || ^8.11.0 || ^9.0.2",
-                "orchestra/testbench": "^7.33.0 || ^8.13.0 || ^9.0.3",
+                "orchestra/testbench-core": "^7.33.0 || ^8.13.0 || ^9.0.9",
+                "phpstan/phpstan-deprecation-rules": "^1.2",
                 "phpunit/phpunit": "^9.6.13 || ^10.5.16"
             },
             "suggest": {
             },
             "type": "phpstan-extension",
             "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0-dev"
-                },
                 "phpstan": {
                     "includes": [
                         "extension.neon"
                     ]
+                },
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
                 }
             },
             "autoload": {
                     "email": "[email protected]"
                 }
             ],
-            "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan wrapper for Laravel",
+            "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
             "keywords": [
                 "PHPStan",
                 "code analyse",
             ],
             "support": {
                 "issues": "https://github.com/larastan/larastan/issues",
-                "source": "https://github.com/larastan/larastan/tree/v2.9.8"
+                "source": "https://github.com/larastan/larastan/tree/v2.9.12"
             },
             "funding": [
-                {
-                    "url": "https://www.paypal.com/paypalme/enunomaduro",
-                    "type": "custom"
-                },
                 {
                     "url": "https://github.com/canvural",
                     "type": "github"
-                },
-                {
-                    "url": "https://github.com/nunomaduro",
-                    "type": "github"
-                },
-                {
-                    "url": "https://www.patreon.com/nunomaduro",
-                    "type": "patreon"
                 }
             ],
-            "time": "2024-07-06T17:46:02+00:00"
+            "time": "2024-11-26T23:09:02+00:00"
         },
         {
             "name": "mockery/mockery",
         },
         {
             "name": "myclabs/deep-copy",
-            "version": "1.12.0",
+            "version": "1.12.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c"
+                "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c",
-                "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845",
+                "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/myclabs/DeepCopy/issues",
-                "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0"
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-06-12T14:39:25+00:00"
+            "time": "2024-11-08T17:47:46+00:00"
         },
         {
             "name": "nunomaduro/collision",
-            "version": "v7.10.0",
+            "version": "v7.11.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nunomaduro/collision.git",
-                "reference": "49ec67fa7b002712da8526678abd651c09f375b2"
+                "reference": "994ea93df5d4132f69d3f1bd74730509df6e8a05"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nunomaduro/collision/zipball/49ec67fa7b002712da8526678abd651c09f375b2",
-                "reference": "49ec67fa7b002712da8526678abd651c09f375b2",
+                "url": "https://api.github.com/repos/nunomaduro/collision/zipball/994ea93df5d4132f69d3f1bd74730509df6e8a05",
+                "reference": "994ea93df5d4132f69d3f1bd74730509df6e8a05",
                 "shasum": ""
             },
             "require": {
-                "filp/whoops": "^2.15.3",
+                "filp/whoops": "^2.16.0",
                 "nunomaduro/termwind": "^1.15.1",
                 "php": "^8.1.0",
-                "symfony/console": "^6.3.4"
+                "symfony/console": "^6.4.12"
             },
             "conflict": {
                 "laravel/framework": ">=11.0.0"
             },
             "require-dev": {
-                "brianium/paratest": "^7.3.0",
-                "laravel/framework": "^10.28.0",
-                "laravel/pint": "^1.13.3",
-                "laravel/sail": "^1.25.0",
-                "laravel/sanctum": "^3.3.1",
-                "laravel/tinker": "^2.8.2",
-                "nunomaduro/larastan": "^2.6.4",
-                "orchestra/testbench-core": "^8.13.0",
-                "pestphp/pest": "^2.23.2",
-                "phpunit/phpunit": "^10.4.1",
-                "sebastian/environment": "^6.0.1",
-                "spatie/laravel-ignition": "^2.3.1"
+                "brianium/paratest": "^7.3.1",
+                "laravel/framework": "^10.48.22",
+                "laravel/pint": "^1.18.1",
+                "laravel/sail": "^1.36.0",
+                "laravel/sanctum": "^3.3.3",
+                "laravel/tinker": "^2.10.0",
+                "nunomaduro/larastan": "^2.9.8",
+                "orchestra/testbench-core": "^8.28.3",
+                "pestphp/pest": "^2.35.1",
+                "phpunit/phpunit": "^10.5.36",
+                "sebastian/environment": "^6.1.0",
+                "spatie/laravel-ignition": "^2.8.0"
             },
             "type": "library",
             "extra": {
                     "type": "patreon"
                 }
             ],
-            "time": "2023-10-11T15:45:01+00:00"
+            "time": "2024-10-15T15:12:40+00:00"
         },
         {
             "name": "phar-io/manifest",
         },
         {
             "name": "phpmyadmin/sql-parser",
-            "version": "5.10.0",
+            "version": "5.10.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpmyadmin/sql-parser.git",
-                "reference": "91d980ab76c3f152481e367f62b921adc38af451"
+                "reference": "b14fd66496a22d8dd7f7e2791edd9e8674422f17"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/91d980ab76c3f152481e367f62b921adc38af451",
-                "reference": "91d980ab76c3f152481e367f62b921adc38af451",
+                "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/b14fd66496a22d8dd7f7e2791edd9e8674422f17",
+                "reference": "b14fd66496a22d8dd7f7e2791edd9e8674422f17",
                 "shasum": ""
             },
             "require": {
                     "type": "other"
                 }
             ],
-            "time": "2024-08-29T20:56:34+00:00"
+            "time": "2024-11-10T04:10:31+00:00"
         },
         {
             "name": "phpstan/phpstan",
-            "version": "1.12.5",
+            "version": "1.12.11",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpstan/phpstan.git",
-                "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17"
+                "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17",
-                "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0d1fc20a962a91be578bcfe7cf939e6e1a2ff733",
+                "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733",
                 "shasum": ""
             },
             "require": {
                     "type": "github"
                 }
             ],
-            "time": "2024-09-26T12:45:22+00:00"
+            "time": "2024-11-17T14:08:01+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
         },
         {
             "name": "phpunit/phpunit",
-            "version": "10.5.35",
+            "version": "10.5.38",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "7ac8b4e63f456046dcb4c9787da9382831a1874b"
+                "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7ac8b4e63f456046dcb4c9787da9382831a1874b",
-                "reference": "7ac8b4e63f456046dcb4c9787da9382831a1874b",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a86773b9e887a67bc53efa9da9ad6e3f2498c132",
+                "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132",
                 "shasum": ""
             },
             "require": {
                 "phpunit/php-timer": "^6.0.0",
                 "sebastian/cli-parser": "^2.0.1",
                 "sebastian/code-unit": "^2.0.0",
-                "sebastian/comparator": "^5.0.2",
+                "sebastian/comparator": "^5.0.3",
                 "sebastian/diff": "^5.1.1",
                 "sebastian/environment": "^6.1.0",
                 "sebastian/exporter": "^5.1.2",
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.35"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.38"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-19T10:52:21+00:00"
+            "time": "2024-10-28T13:06:21+00:00"
         },
         {
             "name": "sebastian/cli-parser",
         },
         {
             "name": "sebastian/comparator",
-            "version": "5.0.2",
+            "version": "5.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/comparator.git",
-                "reference": "2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53"
+                "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53",
-                "reference": "2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e",
+                "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e",
                 "shasum": ""
             },
             "require": {
                 "sebastian/exporter": "^5.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^10.4"
+                "phpunit/phpunit": "^10.5"
             },
             "type": "library",
             "extra": {
             "support": {
                 "issues": "https://github.com/sebastianbergmann/comparator/issues",
                 "security": "https://github.com/sebastianbergmann/comparator/security/policy",
-                "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.2"
+                "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2024-08-12T06:03:08+00:00"
+            "time": "2024-10-18T14:56:07+00:00"
         },
         {
             "name": "sebastian/complexity",
         },
         {
             "name": "squizlabs/php_codesniffer",
-            "version": "3.10.3",
+            "version": "3.11.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
-                "reference": "62d32998e820bddc40f99f8251958aed187a5c9c"
+                "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/62d32998e820bddc40f99f8251958aed187a5c9c",
-                "reference": "62d32998e820bddc40f99f8251958aed187a5c9c",
+                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/19473c30efe4f7b3cd42522d0b2e6e7f243c6f87",
+                "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87",
                 "shasum": ""
             },
             "require": {
                     "type": "open_collective"
                 }
             ],
-            "time": "2024-09-18T10:38:58+00:00"
+            "time": "2024-11-16T12:02:36+00:00"
         },
         {
             "name": "ssddanbrown/asserthtml",
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v6.4.12",
+            "version": "v6.4.16",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "9d307ecbcb917001692be333cdc58f474fdb37f0"
+                "reference": "4304e6ad5c894a9c72831ad459f627bfd35d766d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/9d307ecbcb917001692be333cdc58f474fdb37f0",
-                "reference": "9d307ecbcb917001692be333cdc58f474fdb37f0",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4304e6ad5c894a9c72831ad459f627bfd35d766d",
+                "reference": "4304e6ad5c894a9c72831ad459f627bfd35d766d",
                 "shasum": ""
             },
             "require": {
             "description": "Eases DOM navigation for HTML and XML documents",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/dom-crawler/tree/v6.4.12"
+                "source": "https://github.com/symfony/dom-crawler/tree/v6.4.16"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-15T06:35:36+00:00"
+            "time": "2024-11-13T15:06:22+00:00"
         },
         {
             "name": "theseer/tokenizer",
         "ext-mbstring": "*",
         "ext-xml": "*"
     },
-    "platform-dev": [],
+    "platform-dev": {},
     "platform-overrides": {
         "php": "8.1.0"
     },
diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php
new file mode 100644 (file)
index 0000000..5d0b4f8
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace Database\Factories\Exports;
+
+use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Str;
+
+class ImportFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = \BookStack\Exports\Import::class;
+
+    /**
+     * Define the model's default state.
+     */
+    public function definition(): array
+    {
+        return [
+            'path' => 'uploads/files/imports/' . Str::random(10) . '.zip',
+            'name' => $this->faker->words(3, true),
+            'type' => 'book',
+            'metadata' => '{"name": "My book"}',
+            'created_at' => User::factory(),
+        ];
+    }
+}
index 21f45aa0691b28e147dba9c85eed20842acf3ab4..99416f9fcf62add4899a0a6f38cca59045029fd3 100644 (file)
@@ -11,8 +11,7 @@ return new class extends Migration
      */
     public function up(): void
     {
-        // Create new templates-manage permission and assign to admin role
-        $roles = DB::table('roles')->get('id');
+        // Create new content-export permission
         $permissionId = DB::table('role_permissions')->insertGetId([
             'name'         => 'content-export',
             'display_name' => 'Export Content',
@@ -20,6 +19,7 @@ return new class extends Migration
             'updated_at'   => Carbon::now()->toDateTimeString(),
         ]);
 
+        $roles = DB::table('roles')->get('id');
         $permissionRoles = $roles->map(function ($role) use ($permissionId) {
             return [
                 'role_id'       => $role->id,
@@ -27,6 +27,7 @@ return new class extends Migration
             ];
         })->values()->toArray();
 
+        // Assign to all existing roles in the system
         DB::table('permission_role')->insert($permissionRoles);
     }
 
@@ -40,6 +41,6 @@ return new class extends Migration
             ->where('name', '=', 'content-export')->first();
 
         DB::table('permission_role')->where('permission_id', '=', $contentExportPermission->id)->delete();
-        DB::table('role_permissions')->where('id', '=', 'content-export')->delete();
+        DB::table('role_permissions')->where('id', '=', $contentExportPermission->id)->delete();
     }
 };
diff --git a/database/migrations/2024_10_29_114420_add_import_role_permission.php b/database/migrations/2024_10_29_114420_add_import_role_permission.php
new file mode 100644 (file)
index 0000000..17bbe4c
--- /dev/null
@@ -0,0 +1,61 @@
+<?php
+
+use Carbon\Carbon;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        // Create new content-import permission
+        $permissionId = DB::table('role_permissions')->insertGetId([
+            'name'         => 'content-import',
+            'display_name' => 'Import Content',
+            'created_at'   => Carbon::now()->toDateTimeString(),
+            'updated_at'   => Carbon::now()->toDateTimeString(),
+        ]);
+
+        // Get existing admin-level role ids
+        $settingManagePermission = DB::table('role_permissions')
+            ->where('name', '=', 'settings-manage')->first();
+
+        if (!$settingManagePermission) {
+            return;
+        }
+
+        $adminRoleIds = DB::table('permission_role')
+            ->where('permission_id', '=', $settingManagePermission->id)
+            ->pluck('role_id')->all();
+
+        // Assign the new permission to all existing admins
+        $newPermissionRoles = array_values(array_map(function ($roleId) use ($permissionId) {
+            return [
+                'role_id'       => $roleId,
+                'permission_id' => $permissionId,
+            ];
+        }, $adminRoleIds));
+
+        DB::table('permission_role')->insert($newPermissionRoles);
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        // Remove content-import permission
+        $importPermission = DB::table('role_permissions')
+            ->where('name', '=', 'content-import')->first();
+
+        if (!$importPermission) {
+            return;
+        }
+
+        DB::table('permission_role')->where('permission_id', '=', $importPermission->id)->delete();
+        DB::table('role_permissions')->where('id', '=', $importPermission->id)->delete();
+    }
+};
diff --git a/database/migrations/2024_11_02_160700_create_imports_table.php b/database/migrations/2024_11_02_160700_create_imports_table.php
new file mode 100644 (file)
index 0000000..0784591
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('imports', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('name');
+            $table->string('path');
+            $table->integer('size');
+            $table->string('type');
+            $table->longText('metadata');
+            $table->integer('created_by')->index();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('imports');
+    }
+};
diff --git a/database/migrations/2024_11_27_171039_add_instance_id_setting.php b/database/migrations/2024_11_27_171039_add_instance_id_setting.php
new file mode 100644 (file)
index 0000000..ee1e90d
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        DB::table('settings')->insert([
+            'setting_key' => 'instance-id',
+            'value' => Str::uuid(),
+            'created_at' => Carbon::now(),
+            'updated_at' => Carbon::now(),
+            'type' => 'string',
+        ]);
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        DB::table('settings')->where('setting_key', '=', 'instance-id')->delete();
+    }
+};
index fea8c01e353887d2bb169d330fec1bc51b997a0a..cd8bf279f28db0858cbd3c238ea029d4582d107d 100644 (file)
@@ -10,7 +10,7 @@ const isProd = process.argv[2] === 'production';
 
 // Gather our input files
 const entryPoints = {
-    app: path.join(__dirname, '../../resources/js/app.js'),
+    app: path.join(__dirname, '../../resources/js/app.ts'),
     code: path.join(__dirname, '../../resources/js/code/index.mjs'),
     'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
     markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),
diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md
new file mode 100644 (file)
index 0000000..754cb4d
--- /dev/null
@@ -0,0 +1,160 @@
+# Portable ZIP File Format
+
+BookStack provides exports in a "Portable ZIP" which allows the portable transfer, storage, import & export of BookStack content.
+This document details the format used, and is intended for our own internal development use in addition to detailing the format for potential external use-cases (readers, apps, import for other platforms etc...).
+
+**Note:** This is not a BookStack backup format! This format misses much of the data that would be needed to re-create/restore a BookStack instance. There are existing better alternative options for this use-case.
+
+## Stability
+
+Following the goals & ideals of BookStack, stability is very important. We aim for this defined format to be stable and forwards compatible, to prevent breakages in use-case due to changes. Here are the general rules we follow in regard to stability & changes:
+
+- New features & properties may be added with any release.
+- Where reasonably possible, we will attempt to avoid modifications/removals of existing features/properties.
+- Where potentially breaking changes do have to be made, these will be noted in BookStack release/update notes.
+
+The addition of new features/properties alone are not considered as a breaking change to the format. Breaking changes are considered as such where they could impact common/expected use of the existing properties and features we document, they are not considered based upon user assumptions or any possible breakage.
+For example if your application, using the format, breaks because we added a new property while you hard-coded your application to use the third property (instead of a property name), then that's on you.
+
+## Format Outline
+
+The format is intended to be very simple, readable and based on open standards that could be easily read/handled in most common programming languages.
+The below outlines the structure of the format:
+
+- **ZIP archive container**
+   - **data.json** - Export data.
+   - **files/** - Directory containing referenced files.
+     - *file-a*
+     - *file-b*
+     - *...*
+
+## References
+
+Some properties in the export data JSON are indicated as `String reference`, and these are direct references to a file name within the `files/` directory of the ZIP. For example, the below book cover is directly referencing a `files/4a5m4a.jpg` within the ZIP which would be expected to exist.
+
+```json
+{
+  "book": {
+    "cover": "4a5m4a.jpg"
+  }
+}
+```
+
+Within HTML and markdown content, you may require references across to other items within the export content.
+This can be done using the following format:
+
+```
+[[bsexport:<object>:<reference>]]
+```
+
+References are to the `id` for data objects.
+Here's an example of each type of such reference that could be used:
+
+```
+[[bsexport:image:22]]
+[[bsexport:attachment:55]]
+[[bsexport:page:40]]
+[[bsexport:chapter:2]]
+[[bsexport:book:8]]
+```
+
+## HTML & Markdown Content
+
+BookStack commonly stores & utilises content in the HTML format.
+Properties that expect or provided HTML will either be named `html` or contain `html` in the property name.
+While BookStack supports a range of HTML, not all HTML content will be supported by BookStack and be assured to work as desired across all BookStack features.
+The HTML supported by BookStack is not yet formally documented, but you can inspect to what the WYSIWYG editor produces as a basis.
+Generally, top-level elements should keep to common block formats (p, blockquote, h1, h2 etc...) with no nesting or custom structure apart from common inline elements.
+Some areas of BookStack where HTML is used, like book & chapter descriptions, will strictly limit/filter HTML tag & attributes to an allow-list.
+
+For markdown content, in BookStack we target [the commonmark spec](https://commonmark.org/) with the addition of tables & task-lists.
+HTML within markdown is supported but not all HTML is assured to work as advised above.
+
+### Content Security
+
+If you're consuming HTML or markdown within an export please consider that the content is not assured to be safe, even if provided directly by a BookStack instance. It's best to treat such content as potentially unsafe.
+By default, BookStack performs some basic filtering to remove scripts among other potentially dangerous elements but this is not foolproof. BookStack itself relies on additional security mechanisms such as [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to help prevent a range of exploits.
+
+## Export Data - `data.json`
+
+The `data.json` file is a JSON format file which contains all structured data for the export. The properties are as follows:
+
+- `instance` - [Instance](#instance) Object, optional, details of the export source instance.
+- `exported_at` - String, optional, full ISO 8601 datetime of when the export was created.
+- `book` - [Book](#book) Object, optional, book export data.
+- `chapter` - [Chapter](#chapter) Object, optional, chapter export data.
+- `page` - [Page](#page) Object, optional, page export data.
+
+Either `book`, `chapter` or `page` will exist depending on export type. You'd want to check for each to check what kind of export this is, and if it's an export you can handle. It's possible that other options are added in the future (`books` for a range of books for example) so it'd be wise to specifically check for properties that can be handled, otherwise error to indicate lack of support.
+
+## Data Objects
+
+The below details the objects & their properties used in Application Data.
+
+#### Instance
+
+These details are informational regarding the exporting BookStack instance from where an export was created from.
+
+- `id` - String, required, unique identifier for the BookStack instance.
+- `version` - String, required, BookStack version of the export source instance.
+
+#### Book
+
+- `id` - Number, optional, original ID for the book from exported system.
+- `name` - String, required, name/title of the book.
+- `description_html` - String, optional, HTML description content.
+- `cover` - String reference, optional, reference to book cover image.
+- `chapters` - [Chapter](#chapter) array, optional, chapters within this book.
+- `pages` - [Page](#page) array, optional, direct child pages for this book.
+- `tags` - [Tag](#tag) array, optional, tags assigned to this book.
+
+The `pages` are not all pages within the book, just those that are direct children (not in a chapter). To build an ordered mixed chapter/page list for the book, as what you'd see in BookStack, you'd need to combine `chapters` and `pages` together and sort by their `priority` value (low to high).
+
+#### Chapter
+
+- `id` - Number, optional, original ID for the chapter from exported system.
+- `name` - String, required, name/title of the chapter.
+- `description_html` - String, optional, HTML description content.
+- `priority` - Number, optional, integer order for when shown within a book (shown low to high).
+- `pages` - [Page](#page) array, optional, pages within this chapter.
+- `tags` - [Tag](#tag) array, optional, tags assigned to this chapter.
+
+#### Page
+
+- `id` - Number, optional, original ID for the page from exported system.
+- `name` - String, required, name/title of the page.
+- `html` - String, optional, page HTML content.
+- `markdown` - String, optional, user markdown content for this page.
+- `priority` - Number, optional, integer order for when shown within a book (shown low to high).
+- `attachments` - [Attachment](#attachment) array, optional, attachments uploaded to this page.
+- `images` - [Image](#image) array, optional, images used in this page.
+- `tags` - [Tag](#tag) array, optional, tags assigned to this page.
+
+To define the page content, either `markdown` or `html` should be provided. Ideally these should be limited to the range of markdown and HTML which BookStack supports. See the ["HTML & Markdown Content"](#html--markdown-content) section.
+
+The page editor type, and edit content will be determined by what content is provided. If non-empty `markdown` is provided, the page will be assumed as a markdown editor page (where permissions allow) and the HTML will be rendered from the markdown content. Otherwise, the provided `html` will be used as editor & display content.
+
+#### Image
+
+- `id` - Number, optional, original ID for the page from exported system.
+- `name` - String, required, name of image.
+- `file` - String reference, required, reference to image file.
+- `type` - String, required, must be 'gallery' or 'drawio'
+
+File must be an image type accepted by BookStack (png, jpg, gif, webp).
+Images of type 'drawio' are expected to be png with draw.io drawing data
+embedded within it.
+
+#### Attachment
+
+- `id` - Number, optional, original ID for the attachment from exported system.
+- `name` - String, required, name of attachment.
+- `link` - String, semi-optional, URL of attachment.
+- `file` - String reference, semi-optional, reference to attachment file.
+
+Either `link` or `file` must be present, as that will determine the type of attachment. 
+
+#### Tag
+
+- `name` - String, required, name of the tag.
+- `value` - String, optional, value of the tag (can be empty).
\ No newline at end of file
index 8507cb4cb61f926ab27adcb48ef0932b6375320a..747ccc986d75580778530de8c0ea132bf1cbb00c 100644 (file)
@@ -415,7 +415,7 @@ predis/predis
 License: MIT
 License File: vendor/predis/predis/LICENSE
 Copyright: Copyright (c) 2009-2020 Daniele Alessandri (original work)
-Copyright (c) 2021-2023 Till Krüss (modified work)
+Copyright (c) 2021-2024 Till Krüss (modified work)
 Source: https://github.com/predis/predis.git
 Link: http://github.com/predis/predis
 -----------
@@ -514,7 +514,7 @@ Link: https://github.com/ramsey/uuid.git
 robrichards/xmlseclibs
 License: BSD-3-Clause
 License File: vendor/robrichards/xmlseclibs/LICENSE
-Copyright: Copyright (c) 2007-2019, Robert Richards <*********@*********.***>.
+Copyright: Copyright (c) 2007-2024, Robert Richards <*********@*********.***>.
 Source: https://github.com/robrichards/xmlseclibs.git
 Link: https://github.com/robrichards/xmlseclibs
 -----------
index fb434699e2da9eb97ecc3be5425f71ff174706ea..0d3b12f8fc649052c29bfd65fb8cced72313ba5b 100644 (file)
@@ -89,8 +89,8 @@ return [
     'mfa_setup_action' => 'إعداد (تنصيب)',
     'mfa_backup_codes_usage_limit_warning' => '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.',
     'mfa_option_totp_title' => 'تطبيق الجوال',
-    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_totp_desc' => 'لاستخدام المصادقة المتعددة العوامل، ستحتاج إلى تطبيق محمول يدعم TOTP مثل Google Authenticator أو Authy أو Microsoft Authenticer.',
+    'mfa_option_backup_codes_title' => 'رموز النسخ الاحتياطي',
     '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_gen_confirm_and_enable' => 'Confirm and Enable',
     'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
index d0bf9cf4577ca2c96804bcf010c18975f515eaf9..412bf9d3be330fa4badf86472254e45897cfb6fa 100644 (file)
@@ -46,7 +46,7 @@ return [
     'bookshelf_create_from_book_notification'    => 'Kniha byla úspěšně převedena na polici',
     'bookshelf_update'                 => 'aktualizovat polici',
     'bookshelf_update_notification'    => 'Police byla úspěšně aktualizována',
-    'bookshelf_delete'                 => 'odstranit knihovnu',
+    'bookshelf_delete'                 => 'odstranil polici',
     'bookshelf_delete_notification'    => 'Police byla úspěšně odstraněna',
 
     // Revisions
index 1e20b17e7dcad4531ac0919d4323144570bd226f..66ad222792d712d85dd587712c5ebd83022c2a40 100644 (file)
@@ -109,5 +109,5 @@ return [
     'terms_of_service' => 'Podmínky služby',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Vyhledat :appName',
 ];
index c73dd6f1f1127f97c02c3c682f2d89c1575d7d07..77fb8a3fb95693bd19283df7cec553ad34d4de4b 100644 (file)
@@ -83,8 +83,8 @@ return [
     'search_update' => 'Aktualizovat hledání',
 
     // Shelves
-    'shelf' => 'Knihovna',
-    'shelves' => 'Knihovny',
+    'shelf' => 'Police',
+    'shelves' => 'Police',
     'x_shelves' => '{0}:count polic|{1}:count police|[2,4]:count police|[5,*]:count polic',
     'shelves_empty' => 'Nebyly vytvořeny žádné police',
     'shelves_create' => 'Vytvořit novou polici',
@@ -224,8 +224,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(Vytvořený obsah)',
     'pages_edit_switch_to_markdown_stable' => '(Stabilní obsah)',
     'pages_edit_switch_to_wysiwyg' => 'Přepnout na WYSIWYG Editor',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'Přepnout na nový WYSIWYG',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(V alfa testování)',
     'pages_edit_set_changelog' => 'Nastavit protokol změn',
     'pages_edit_enter_changelog_desc' => 'Zadejte stručný popis změn, které jste provedli',
     'pages_edit_enter_changelog' => 'Zadejte protokol změn',
index a1efdadb7ae094c4eaa221516bcc37ba78389111..e23f1032b7d106a32a2d043d224cc2c3027a642a 100644 (file)
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Nemůžete odstranit posledního administrátora',
     'users_cannot_delete_guest' => 'Uživatele Host není možno odstranit',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Nebylo možné vytvořit uživatele, protože se nepodařilo odeslat email s pozvánkou',
 
     // Roles
     'role_cannot_be_edited' => 'Tuto roli nelze editovat',
index 6a99fec6f92831a3889084b5b4972bceada3af36..e92d1c165a53b121409332898a38b41b2bacb509 100644 (file)
@@ -63,17 +63,17 @@ return [
 
     // Auth
     'auth_login' => 'loggede ind',
-    'auth_register' => 'registered as new user',
+    'auth_register' => 'registreret som ny bruger',
     'auth_password_reset_request' => 'requested user password reset',
     'auth_password_reset_update' => 'nulstil adgangskode',
-    'mfa_setup_method' => 'configured MFA method',
+    'mfa_setup_method' => 'konfigureret MFA metode',
     'mfa_setup_method_notification' => 'Multi-faktor metode konfigureret',
-    'mfa_remove_method' => 'removed MFA method',
+    'mfa_remove_method' => 'fjernet MFA metode',
     'mfa_remove_method_notification' => 'Multi-faktor metode fjernet',
 
     // Settings
-    'settings_update' => 'updated settings',
-    'settings_update_notification' => 'Settings successfully updated',
+    'settings_update' => 'opdaterede indstillinger',
+    'settings_update_notification' => 'Indstillinger opdateret',
     'maintenance_action_run' => 'ran maintenance action',
 
     // Webhooks
@@ -87,13 +87,13 @@ return [
     // Users
     'user_create' => 'opret bruger',
     'user_create_notification' => 'Bruger oprettet korrekt',
-    'user_update' => 'updated user',
+    'user_update' => 'opdateret bruger',
     'user_update_notification' => 'Brugeren blev opdateret',
-    'user_delete' => 'deleted user',
+    'user_delete' => 'slettet bruger',
     'user_delete_notification' => 'Brugeren blev fjernet',
 
     // API Tokens
-    'api_token_create' => 'created API token',
+    'api_token_create' => 'oprettet API token',
     'api_token_create_notification' => 'API token successfully created',
     'api_token_update' => 'updated API token',
     'api_token_update_notification' => 'API token successfully updated',
@@ -101,12 +101,12 @@ return [
     'api_token_delete_notification' => 'API token successfully deleted',
 
     // Roles
-    'role_create' => 'created role',
-    'role_create_notification' => 'Role successfully created',
-    'role_update' => 'updated role',
-    'role_update_notification' => 'Role successfully updated',
-    'role_delete' => 'deleted role',
-    'role_delete_notification' => 'Role successfully deleted',
+    'role_create' => 'oprettet rolle',
+    'role_create_notification' => 'Rolle oprettet',
+    'role_update' => 'opdateret rolle',
+    'role_update_notification' => 'Rolle opdateret',
+    'role_delete' => 'slettet rolle',
+    'role_delete_notification' => 'Rollen blev slettet',
 
     // Recycle Bin
     'recycle_bin_empty' => 'emptied recycle bin',
index 5fa2d156f1edbc094f82f5180e04a13537a0d1d2..fa802af16f0878fa0c27f69aa2f1de7f92552f52 100644 (file)
@@ -33,7 +33,7 @@ return [
     'app_custom_html_disabled_notice' => 'Brugerdefineret HTML head indhold er deaktiveret på denne indstillingsside for at, at ændringer kan rulles tilbage.',
     'app_logo' => 'Applikationslogo',
     'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
-    'app_icon' => 'Application Icon',
+    'app_icon' => 'Program ikon',
     'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
     'app_homepage' => 'Applikationsforside',
     'app_homepage_desc' => 'Vælg en visning, der skal vises på forsiden i stedet for standardvisningen. Sidetilladelser ignoreres for de valgte sider.',
index 092398ef0e1475089fa71b7e60aa1f49fc056013..7c3454d41ca287406316a6eb3f528250c5361c3d 100644 (file)
@@ -84,6 +84,14 @@ return [
     'webhook_delete' => 'deleted webhook',
     'webhook_delete_notification' => 'Webhook successfully deleted',
 
+    // Imports
+    'import_create' => 'created import',
+    'import_create_notification' => 'Import successfully uploaded',
+    'import_run' => 'updated import',
+    'import_run_notification' => 'Content successfully imported',
+    'import_delete' => 'deleted import',
+    'import_delete_notification' => 'Import successfully deleted',
+
     // Users
     'user_create' => 'created user',
     'user_create_notification' => 'User successfully created',
index 35e6f050bb885cc28cb83e5064fed910778c1ae1..26a563a7eb534388afd1e89fce92ea91e1a8a5c7 100644 (file)
@@ -39,9 +39,30 @@ return [
     'export_pdf' => 'PDF File',
     'export_text' => 'Plain Text File',
     'export_md' => 'Markdown File',
+    '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.',
+    '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:',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
index 9c40aa9ed339578a1585ddefda68760cd2563c8e..9d738379648d26fd0b59ea0a0c82f53edf1e4b38 100644 (file)
@@ -105,6 +105,18 @@ return [
     'app_down' => ':appName is down right now',
     'back_soon' => 'It will be back up 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_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.',
+
     // 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',
index 5427cb9419ea1ff716704e5b06630b9afcd2c3b2..c0b6b692a57b9b5eaf7ce17d51a0baf0ae6380ab 100644 (file)
@@ -162,6 +162,7 @@ return [
     '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_asset' => 'Asset Permissions',
index 2a676c7c4cce0130591a929cefd8a21e7765be1c..d9b982d1e23e2eb2ad98ae4815e8b94de20f11dc 100644 (file)
@@ -105,6 +105,11 @@ return [
     'url'                  => 'The :attribute format is invalid.',
     'uploaded'             => 'The file could not be uploaded. The server may not accept files of this size.',
 
+    'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
+    'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.',
+    'zip_model_expected' => 'Data object expected but ":type" found.',
+    'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
+
     // Custom validation lines
     'custom' => [
         'password-confirm' => [
index a53060a00e652ff421f8abfd304fa86668ba7150..3eb6b50b96eed8a4568fbe93b13addf62763506b 100644 (file)
@@ -109,5 +109,5 @@ return [
     'terms_of_service' => 'Kasutustingimused',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Otsi :appName',
 ];
index 0d90dd32f3c14ef313b6238ea4d716c40e185a47..dd875a6f2dae3979485d63c7dde2d947739cd79b 100644 (file)
@@ -224,8 +224,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(Puhas sisu)',
     'pages_edit_switch_to_markdown_stable' => '(Stabiilne sisu)',
     'pages_edit_switch_to_wysiwyg' => 'Kasuta WYSIWYG redaktorit',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'Kasuta uut tekstiredaktorit',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(Alfa-testimisel)',
     'pages_edit_set_changelog' => 'Muudatuste logi',
     'pages_edit_enter_changelog_desc' => 'Sisesta tehtud muudatuste lühikirjeldus',
     'pages_edit_enter_changelog' => 'Salvesta muudatuste logi',
index a4c3cf2bdcb8395f042c34a7a17349690e730f5c..732c945295f3daec00b9626c55d22c3289d04e55 100644 (file)
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Ainsat administraatorit ei saa kustutada',
     'users_cannot_delete_guest' => 'Külaliskasutajat ei saa kustutada',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Kasutajat ei saadud luua, kuna kutse e-kirja saatmine ebaõnnestus',
 
     // Roles
     'role_cannot_be_edited' => 'Seda rolli ei saa muuta',
index 282a72e4569e3018155c360e9e3f21a1f4c87e3c..0abff99542a57c9aac07831638b8c0b662973d60 100644 (file)
@@ -109,5 +109,5 @@ return [
     'terms_of_service' => 'Conditions d\'utilisation',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Recherche :appName',
 ];
index 1f826bb7ee7ceb327a6f4bf83d00ada7d20b2541..b92ddac0ac7e510614b53ad2b7da88206947a01c 100644 (file)
@@ -224,8 +224,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(Contenu nettoyé)',
     'pages_edit_switch_to_markdown_stable' => '(Contenu stable)',
     'pages_edit_switch_to_wysiwyg' => 'Basculer vers l\'éditeur WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'Basculer vers le nouveau WYSIWYG',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(En test Alpha)',
     'pages_edit_set_changelog' => 'Remplir le journal des changements',
     'pages_edit_enter_changelog_desc' => 'Entrez une brève description des changements effectués',
     'pages_edit_enter_changelog' => 'Ouvrir le journal des changements',
index f2c9b125f37097f4165ad8ab2537487a3d0c2e2d..7658fd8a982e989c22e402001f649825862c7a46 100644 (file)
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Vous ne pouvez pas supprimer le dernier administrateur',
     'users_cannot_delete_guest' => 'Vous ne pouvez pas supprimer l\'utilisateur invité',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Impossible de créer l\'utilisateur à cause d\'une erreur d\'envoi de l\'email d\'invitation',
 
     // Roles
     'role_cannot_be_edited' => 'Ce rôle ne peut pas être modifié',
index 8ce859127a5b02ac21ee0d22e75412b78c8c447b..75ab9b3ea283c7fc5c3ac1f1cfa3e4f9e79c484b 100644 (file)
@@ -62,61 +62,61 @@ return [
     'watch_update_level_notification' => 'העדפות צפייה עודכנו בהצלחה',
 
     // Auth
-    'auth_login' => 'logged in',
-    'auth_register' => 'registered as new user',
-    'auth_password_reset_request' => 'requested user password reset',
-    'auth_password_reset_update' => 'reset user password',
-    'mfa_setup_method' => 'configured MFA method',
-    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
-    'mfa_remove_method' => 'removed MFA method',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'auth_login' => 'מחובר',
+    'auth_register' => 'נרשם כמשתמש חדש',
+    'auth_password_reset_request' => 'בקשת איפוס סיסמה למשתמש בוצעה בהצלחה',
+    'auth_password_reset_update' => 'איפוס סיסמה למשתמש',
+    'mfa_setup_method' => 'הגדרת אימות דו-שלבי פעיל',
+    'mfa_setup_method_notification' => 'הגדרת אימות דו-שלבי בוצע בהצלחה',
+    'mfa_remove_method' => 'הגדרת אימות דו-שלבי הוסר',
+    'mfa_remove_method_notification' => 'אפשרות אימות דו-שלבי הוסר בהצלחה',
 
     // Settings
-    'settings_update' => 'updated settings',
-    'settings_update_notification' => 'Settings successfully updated',
-    'maintenance_action_run' => 'ran maintenance action',
+    'settings_update' => 'הגדרות עודכנו בהצלחה',
+    'settings_update_notification' => 'ההגדרות עודכנו בהצלחה',
+    'maintenance_action_run' => 'פעולות תחזוקה שהופעלו',
 
     // Webhooks
-    'webhook_create' => 'created webhook',
-    'webhook_create_notification' => 'Webhook נוצר בהצלחה',
-    'webhook_update' => 'updated webhook',
-    'webhook_update_notification' => 'Webhook successfully updated',
-    'webhook_delete' => 'deleted webhook',
-    'webhook_delete_notification' => 'Webhook successfully deleted',
+    'webhook_create' => 'webook נוצר',
+    'webhook_create_notification' => 'יצירת Webhook בוצעה בהצלחה',
+    'webhook_update' => 'webhook עודכן',
+    'webhook_update_notification' => 'webook עודכן בהצלחה',
+    'webhook_delete' => 'Webhook נמחק',
+    'webhook_delete_notification' => 'Webook נמחק בהצלחה',
 
     // Users
-    'user_create' => 'created user',
-    'user_create_notification' => 'User successfully created',
-    'user_update' => 'updated user',
+    'user_create' => 'משתמש חדש נוצר',
+    'user_create_notification' => 'משתמש נוצר בהצלחה',
+    'user_update' => 'משתמש עודכן',
     'user_update_notification' => 'משתמש עודכן בהצלחה',
-    'user_delete' => 'deleted user',
+    'user_delete' => 'משתמש נמחק',
     'user_delete_notification' => 'משתמש הוסר בהצלחה',
 
     // API Tokens
-    'api_token_create' => 'created API token',
-    'api_token_create_notification' => 'API token successfully created',
-    'api_token_update' => 'updated API token',
-    'api_token_update_notification' => 'API token successfully updated',
-    'api_token_delete' => 'deleted API token',
-    'api_token_delete_notification' => 'API token successfully deleted',
+    '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' => 'created role',
+    'role_create' => 'תפקיד נוצר',
     'role_create_notification' => 'תפקיד נוצר בהצלחה',
-    'role_update' => 'updated role',
+    'role_update' => 'תפקיד עודכן',
     'role_update_notification' => 'תפקיד עודכן בהצלחה',
-    'role_delete' => 'deleted role',
+    'role_delete' => 'תפקיד נמחק',
     'role_delete_notification' => 'תפקיד נמחק בהצלחה',
 
     // Recycle Bin
-    'recycle_bin_empty' => 'emptied recycle bin',
-    'recycle_bin_restore' => 'restored from recycle bin',
-    'recycle_bin_destroy' => 'removed from recycle bin',
+    'recycle_bin_empty' => 'סל המחזור רוקן',
+    'recycle_bin_restore' => 'שוחזר מסל המחזור',
+    'recycle_bin_destroy' => 'נמחק מסל המחזור',
 
     // Comments
     'commented_on'                => 'הגיב/ה על',
-    'comment_create'              => 'added comment',
-    'comment_update'              => 'updated comment',
+    'comment_create'              => 'הערה הוספה',
+    'comment_update'              => 'תגובה הוספה',
     'comment_delete'              => 'תגובה נמחקה',
 
     // Other
index 04045e357587bb1a6e858287003d851a2992f947..017602b4f2a19170c93cf2a4519bb069e81f935c 100644 (file)
@@ -6,12 +6,12 @@
  */
 return [
 
-    'failed' => 'פרטי ההתחברות אינם תואמים את הנתונים שלנו',
-    'throttle' => 'נס×\99×\95× ×\95ת ×\94ת×\97×\91ר×\95ת ×¨×\91ים מדי, יש להמתין :seconds שניות ולנסות שנית.',
+    'failed' => 'פרטי ההתחברות אינם תואמים את הנתונים שלנו.',
+    'throttle' => 'נס×\99×\95× ×\95ת ×\94ת×\97×\91ר×\95ת ×\9e×\94×\99רים מדי, יש להמתין :seconds שניות ולנסות שנית.',
 
     // Login & Register
-    'sign_up' => 'הרשמה',
-    'log_in' => 'התחבר',
+    'sign_up' => 'הרשמה למערכת',
+    'log_in' => 'התחבר למערכת',
     'log_in_with' => 'התחבר באמצעות :socialDriver',
     'sign_up_with' => 'הרשם באמצעות :socialDriver',
     'logout' => 'התנתק',
@@ -72,40 +72,40 @@ return [
 
     // User Invite
     'user_invite_email_subject' => 'הוזמנת להצטרף ל:appName!',
-    'user_invite_email_greeting' => 'An account has been created for you on :appName.',
+    'user_invite_email_greeting' => 'חשבון נוצר עבורך ב :appName.',
     'user_invite_email_text' => 'לחץ על הכפתור מטה בכדי להגדיר סיסמת משתמש ולקבל גישה:',
     'user_invite_email_action' => 'הגדר סיסמה לחשבון',
-    'user_invite_page_welcome' => 'Welcome to :appName!',
-    'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.',
+    'user_invite_page_welcome' => 'ברוכים הבאים ל :appName!',
+    'user_invite_page_text' => 'על מנת לסיים את ההרשמה ולקבל גישה עלייך להגדיר סיסמה אשר תהיה בשימוש בהתחברות ל :appName בביקורים עתידיים.',
     'user_invite_page_confirm_button' => 'אימות סיסמא',
-    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+    'user_invite_success_login' => 'הסיסמה הוגדרה בהצלחה, כעת תוכלו לקבל גישה ל :appName!',
 
     // Multi-factor Authentication
     'mfa_setup' => 'הגדר אימות רב-שלבי',
     'mfa_setup_desc' => 'הגדר אימות רב-שלבי כשכבת אבטחה נוספת עבור החשבון שלך.',
     'mfa_setup_configured' => 'כבר הוגדר',
     'mfa_setup_reconfigure' => 'הגדר מחדש',
-    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
-    'mfa_setup_action' => 'Setup',
+    'mfa_setup_remove_confirmation' => 'האם להסיר את אפשרות האימות הדו-שלבי הזאת?',
+    'mfa_setup_action' => 'הגדרה',
     'mfa_backup_codes_usage_limit_warning' => 'נשאר לך פחות מ 5 קודי גיבוי, בבקשה חולל ואחסן סט חדש לפני שיגמרו לך הקודים בכדי למנוע נעילה מחוץ לחשבון שלך.',
     'mfa_option_totp_title' => 'אפליקציה לנייד',
     'mfa_option_totp_desc' => 'בכדי להשתמש באימות רב-שלבי תצטרך אפליקציית מובייל תומכת TOTP כמו Google Authenticator, Authy או Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'קודי גיבוי',
-    '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_gen_confirm_and_enable' => 'Confirm and Enable',
-    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
-    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
-    'mfa_gen_backup_codes_download' => 'Download Codes',
-    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
-    'mfa_gen_totp_title' => 'Mobile App Setup',
-    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
-    'mfa_gen_totp_verify_setup' => 'Verify Setup',
-    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
-    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
-    'mfa_verify_access' => 'Verify Access',
-    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
-    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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 באמצעות האפליקציה שבה מתבצע הזיהוי על מנת להתחיל.',
+    'mfa_gen_totp_verify_setup' => 'אשר את ההגדרה',
+    'mfa_gen_totp_verify_setup_desc' => 'על מנת לוודא כי הזיהוי הדו-שלבי עובד יש להכניס את הקוד, הוא מופיע לך על מסך האפליקציה, בשדה מטה:',
+    'mfa_gen_totp_provide_code_here' => 'אנא הכנס את הקוד כאן',
+    'mfa_verify_access' => 'אשר גישה',
+    'mfa_verify_access_desc' => 'חשבון המשתמש שלך דורש ממך לאת את הזהות שלך בשכבת הגנה נוספת על מנת לאפשר לך גישה. יש לאשר גישה דרך אחד האמצעים הקיימים על מנת להמשיך.',
+    'mfa_verify_no_methods' => 'אין אפשרויות אימות דו-שלבי מוגדרות',
     'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
     'mfa_verify_use_totp' => 'Verify using a mobile app',
     'mfa_verify_use_backup_codes' => 'Verify using a backup code',
index a427df954fe977ab7b4179c14bf8e68cdf1d1ed3..4e4483ccf0161df5ac4cab2518d45eee23353756 100644 (file)
@@ -20,7 +20,7 @@ return [
     'description' => 'תיאור',
     'role' => 'תפקיד',
     'cover_image' => 'תמונת נושא',
-    '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' => 'התמונה צריכה להיות לפחות בגודל של 440 על 250 למרות שהיא תוצג בצורה דינמית ותותאם בהתאם להתאים לממשק המשתמש במצבים שונים על פי הדרישה, ולכן המימדים יהיו שונים.',
 
     // Actions
     'actions' => 'פעולות',
@@ -52,7 +52,7 @@ return [
     'filter_clear' => 'נקה מסננים',
     'download' => 'הורדה',
     'open_in_tab' => 'פתח בכרטיסייה חדשה',
-    'open' => 'Open',
+    'open' => 'פתח',
 
     // Sort Options
     'sort_options' => 'אפשרויות מיון',
@@ -76,7 +76,7 @@ return [
     'grid_view' => 'תצוגת רשת',
     'list_view' => 'תצוגת רשימה',
     'default' => 'ברירת מחדל',
-    'breadcrumb' => 'Breadcrumb',
+    'breadcrumb' => 'סימון מסלול',
     'status' => 'סטטוס',
     'status_active' => 'פעיל',
     'status_inactive' => 'לא פעיל',
@@ -109,5 +109,5 @@ return [
     'terms_of_service' => 'תנאי שימוש',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'חפש :appName',
 ];
index 9e075d89da98475365b705d8f8af1346c17ba87b..30b9ab01a0987c9fbf0b70f4172cafd8b9f026ab 100644 (file)
@@ -6,36 +6,36 @@ return [
 
     // Image Manager
     'image_select' => 'בחירת תמונה',
-    'image_list' => 'Image List',
-    'image_details' => 'Image Details',
-    'image_upload' => 'Upload Image',
-    'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.',
-    'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.',
+    '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' => 'Uploaded by :userName',
-    'image_uploaded_to' => 'Uploaded to :pageLink',
-    'image_updated' => 'Updated :updateDate',
+    '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' => 'Drop images here to upload',
+    'image_dropzone_drop' => 'גרור תמונות לכאן על מנת להעלאות אותם',
     'images_deleted' => 'התמונות נמחקו',
     'image_preview' => 'תצוגה מקדימה',
     'image_upload_success' => 'התמונה עלתה בהצלחה',
     'image_update_success' => 'פרטי התמונה עודכנו בהצלחה',
     'image_delete_success' => 'התמונה נמחקה בהצלחה',
-    'image_replace' => 'Replace Image',
-    'image_replace_success' => 'Image file successfully updated',
-    'image_rebuild_thumbs' => 'Regenerate Size Variations',
-    'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!',
+    'image_replace' => 'החלפת תמונה',
+    'image_replace_success' => 'תמונה עודכנה בהצלחה',
+    'image_rebuild_thumbs' => 'יצר ווריאציות גודל מחדש',
+    'image_rebuild_thumbs_success' => 'ווריאציות גודל תמונה יוצרו מחדש בהצלחה!',
 
     // Code Editor
     'code_editor' => 'ערוך קוד',
index a94d7c30b36449e94a5a64a5497383c28c25ff9e..3909680e3d45283065c0fa82098c4143844c7c83 100644 (file)
@@ -6,10 +6,10 @@
  */
 return [
 
-    'password' => '×\94ס×\99ס×\9e×\94 חייבת להיות בעלת 8 תווים לפחות ולהתאים לאימות.',
-    'user' => "לא נמצא משתמש עם כתובת דוא\"ל זו.",
-    'token' => '×\90ס×\99×\9e×\95×\9f איפוס הסיסמה לא תקף עבור כתובת דוא"ל זו.',
-    'sent' => 'ש×\9c×\97× ×\95 ×\9c×\9a ×\93×\95×\90\9c ×¢×\9d ×§×\99ש×\95ר ×\9c×\90×\99פ×\95ס ×\94ס×\99ס×\9e×\94!',
+    'password' => '×\94ס×\99ס×\9e×\90 חייבת להיות בעלת 8 תווים לפחות ולהתאים לאימות.',
+    'user' => "×\9c×\90 × ×\9eצ×\90 ×\9eשת×\9eש ×§×\99×\99×\9d ×¢×\9d ×\9bת×\95×\91ת ×\93×\95×\90\"×\9c ×\96×\95.",
+    'token' => '×\94×§×\99ש×\95ר ×\9cאיפוס הסיסמה לא תקף עבור כתובת דוא"ל זו.',
+    'sent' => 'ש×\9c×\97× ×\95 ×\9c×\9a ×\93×\95×\90\9c ×¢×\9d ×§×\99ש×\95ר ×\9c×\90×\99פ×\95ס ×\94ס×\99ס×\9e×\90!',
     'reset' => 'איפוס הסיסמה הושלם בהצלחה!',
 
 ];
index f0f997f26d2a36523b025eeb9494f2b81194be11..9e05eefb3abea2b54f1af7e992d2ecd2a476ae28 100644 (file)
@@ -5,10 +5,10 @@
  */
 
 return [
-    'my_account' => 'My Account',
+    'my_account' => 'החשבון שלי',
 
     'shortcuts' => 'קיצורי דרך',
-    'shortcuts_interface' => 'UI Shortcut Preferences',
+    'shortcuts_interface' => 'העדפות קיצורי ממשק משתמש',
     'shortcuts_toggle_desc' => 'כאן תוכל להפעיל או לבטל קיצורי דרך לממשק מערכת המקלדת, המשמשים לניווט ולפעולות.',
     'shortcuts_customize_desc' => 'אתה יכול להתאים אישית כל אחד מקיצורי הדרך למטה. פשוט לחץ על צירוף המקשים הרצוי לאחר בחירת הקלט לקיצור דרך.',
     'shortcuts_toggle_label' => 'קיצורי מקשים מופעלים',
@@ -17,35 +17,35 @@ return [
     'shortcuts_save' => 'שמור קיצורי דרך',
     'shortcuts_overlay_desc' => 'הערה: כאשר קיצורי דרך מופעלים, שכבת-על מסייעת זמינה באמצעות לחיצה על "?" אשר ידגיש את קיצורי הדרך הזמינים לפעולות הנראות כעת על המסך.',
     'shortcuts_update_success' => 'העדפותיך נשמרו!',
-    'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
+    '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_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_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' => 'אנא בחר תמונה שתייצג אותך כלפי אחרים במערכת, תמונה אידיאלית צריכה להיות בגובה ואורך של 256 פיקסלים.',
+    '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' => 'הפעולה אינה ניתנת לביטול, האם למחוק את החשבון?',
 ];
index 3ad9da5ffbf367e861f9cdc8d02a274e28a1591d..bf585287cf8d297a5a89708f6cc749b30e773b7d 100644 (file)
@@ -9,8 +9,8 @@ return [
     // Common Messages
     'settings' => 'הגדרות',
     'settings_save' => 'שמור הגדרות',
-    'system_version' => 'System Version',
-    'categories' => 'Categories',
+    'system_version' => 'גירסת מערכת',
+    'categories' => 'קטגוריות',
 
     // App Settings
     'app_customization' => 'התאמה אישית',
@@ -26,7 +26,7 @@ return [
     'app_secure_images' => 'העלאת תמונות מאובטחת',
     'app_secure_images_toggle' => 'אפשר העלאת תמונות מאובטחת',
     'app_secure_images_desc' => 'משיקולי ביצועים, כל התמונות הינן ציבוריות. אפשרות זו מוסיפה מחרוזת אקראית שקשה לנחש לכל כתובת של תמונה. אנא ודא שאפשרות הצגת תוכן התיקייה מבוטל.',
-    'app_default_editor' => 'Default Page Editor',
+    'app_default_editor' => 'עורך דפים ברירת מחדל',
     'app_default_editor_desc' => 'Select which editor will be used by default when editing new pages. This can be overridden at a page level where permissions allow.',
     'app_custom_html' => 'HTML מותאם אישית לחלק העליון',
     'app_custom_html_desc' => 'כל קוד שיתווסף כאן, יופיע בתחתית תגית ה head של כל דף. חלק זה שימושי על מנת להגדיר עיצובי CSS והתקנת קוד Analytics',
index 39b6859e4229cc376b99ef1b7b701f0377092214..61a225185519d42485fabc7fecd50149a39ea9ab 100644 (file)
@@ -31,19 +31,19 @@ return [
     'book_create'                 => 'ha creato il libro',
     'book_create_notification'    => 'Libro creato con successo',
     'book_create_from_chapter'              => 'ha convertito da capitolo a libro',
-    'book_create_from_chapter_notification' => 'Capitolo convertito con successo in libro',
+    'book_create_from_chapter_notification' => 'Capitolo convertito in libro con successo',
     'book_update'                 => 'ha aggiornato il libro',
     'book_update_notification'    => 'Libro aggiornato con successo',
     'book_delete'                 => 'ha eliminato il libro',
     'book_delete_notification'    => 'Libro eliminato con successo',
     'book_sort'                   => 'ha ordinato il libro',
-    'book_sort_notification'      => 'Libro reindicizzato con successo',
+    'book_sort_notification'      => 'Libro riordinato con successo',
 
     // Bookshelves
     'bookshelf_create'            => 'ha creato la libreria',
     'bookshelf_create_notification'    => 'Libreria creata con successo',
-    'bookshelf_create_from_book'    => 'ha convertito libro in libreria',
-    'bookshelf_create_from_book_notification'    => 'Libro convertito con successo in libreria',
+    'bookshelf_create_from_book'    => 'ha convertito il libro in libreria',
+    'bookshelf_create_from_book_notification'    => 'Libro convertito in libreria con successo',
     'bookshelf_update'                 => 'ha aggiornato la libreria',
     'bookshelf_update_notification'    => 'Libreria aggiornata con successo',
     'bookshelf_delete'                 => 'ha eliminato la libreria',
@@ -72,53 +72,53 @@ return [
     'mfa_remove_method_notification' => 'Metodo multi-fattore rimosso con successo',
 
     // Settings
-    'settings_update' => 'impostazioni aggiornate',
+    'settings_update' => 'ha aggiornato le impostazioni',
     'settings_update_notification' => 'Impostazioni aggiornate con successo',
-    'maintenance_action_run' => 'eseguita azione di manutenzione',
+    'maintenance_action_run' => 'ha eseguito un\'azione di manutenzione',
 
     // Webhooks
-    'webhook_create' => 'webhook creato',
+    'webhook_create' => 'ha creato un webhook',
     'webhook_create_notification' => 'Webhook creato con successo',
-    'webhook_update' => 'webhook aggiornato',
+    'webhook_update' => 'ha aggiornato un webhook',
     'webhook_update_notification' => 'Webhook aggiornato con successo',
-    'webhook_delete' => 'webhook eliminato',
+    'webhook_delete' => 'ha eliminato un webhook',
     'webhook_delete_notification' => 'Webhook eliminato con successo',
 
     // Users
-    'user_create' => 'utente creato',
+    'user_create' => 'ha creato un utente',
     'user_create_notification' => 'Utente creato con successo',
-    'user_update' => 'utente aggiornato',
+    'user_update' => 'ha aggiornato un utente',
     'user_update_notification' => 'Utente aggiornato con successo',
-    'user_delete' => 'utente eliminato',
+    'user_delete' => 'ha eliminato un utente',
     'user_delete_notification' => 'Utente rimosso con successo',
 
     // API Tokens
-    'api_token_create' => 'token API creato',
+    'api_token_create' => 'ha creato un token API',
     'api_token_create_notification' => 'Token API creato con successo',
-    'api_token_update' => 'token API aggiornato',
+    'api_token_update' => 'ha aggiornato un token API',
     'api_token_update_notification' => 'Token API aggiornato correttamente',
-    'api_token_delete' => 'token API eliminato',
+    'api_token_delete' => 'ha eliminato token API',
     'api_token_delete_notification' => 'Token API eliminato con successo',
 
     // Roles
-    'role_create' => 'creato ruolo',
+    'role_create' => 'ha creato un ruolo',
     'role_create_notification' => 'Ruolo creato con successo',
-    'role_update' => 'aggiornato ruolo',
+    'role_update' => 'ha aggiornato un ruolo',
     'role_update_notification' => 'Ruolo aggiornato con successo',
-    'role_delete' => 'eliminato ruolo',
+    'role_delete' => 'ha eliminato un ruolo',
     'role_delete_notification' => 'Ruolo eliminato con successo',
 
     // Recycle Bin
-    'recycle_bin_empty' => 'cestino svuotato',
-    'recycle_bin_restore' => 'ripristinato dal cestino',
-    'recycle_bin_destroy' => 'rimosso dal cestino',
+    'recycle_bin_empty' => 'ha svuotato il cestino',
+    'recycle_bin_restore' => 'ha ripristinato dal cestino',
+    'recycle_bin_destroy' => 'ha rimosso dal cestino',
 
     // Comments
     'commented_on'                => 'ha commentato in',
-    'comment_create'              => 'commento aggiunto',
-    'comment_update'              => 'commento aggiornato',
-    'comment_delete'              => 'commento rimosso',
+    'comment_create'              => 'ha aggiunto un commento',
+    'comment_update'              => 'ha aggiornato un commento',
+    'comment_delete'              => 'ha rimosso un commento',
 
     // Other
-    'permissions_update'          => 'autorizzazioni aggiornate',
+    'permissions_update'          => 'ha aggiornate le autorizzazioni',
 ];
index 94f1ebd53b06396fdd9cf8dfcfd9bf20a5572692..9191ecba794ca1c787c1a97fe949af6dd378e1e8 100644 (file)
@@ -20,11 +20,11 @@ return [
     'username' => 'Username',
     'email' => 'Email',
     'password' => 'Password',
-    'password_confirm' => 'Conferma Password',
+    'password_confirm' => 'Conferma password',
     'password_hint' => 'Deve essere lunga almeno 8 caratteri',
     'forgot_password' => 'Password dimenticata?',
     'remember_me' => 'Ricordami',
-    'ldap_email_hint' => 'Inserisci un email per usare quest\'account.',
+    'ldap_email_hint' => 'Inserisci un\'email per usare quest\'account.',
     'create_account' => 'Crea un account',
     'already_have_account' => 'Hai già un account?',
     'dont_have_account' => 'Non hai un account?',
@@ -34,54 +34,54 @@ return [
 
     'register_thanks' => 'Grazie per esserti registrato!',
     'register_confirm' => 'Controlla la tua mail e clicca il pulsante di conferma per accedere a :appName.',
-    'registrations_disabled' => 'La registrazione è disabilitata',
-    'registration_email_domain_invalid' => 'Questo dominio della mail non ha accesso a questa applicazione',
-    'register_success' => 'Grazie per la registrazione! Sei registrato e loggato.',
+    'registrations_disabled' => 'La registrazione è disabilitata al momento',
+    'registration_email_domain_invalid' => 'Questo dominio email non ha accesso a questa applicazione',
+    'register_success' => 'Grazie per la registrazione! Sei registrato e connesso.',
 
     // Login auto-initiation
     'auto_init_starting' => 'Tentativo di accesso',
-    'auto_init_starting_desc' => 'Stiamo contattando il vostro sistema di autenticazione per avviare il processo di login. Se dopo 5 secondi non si verifica alcun progresso, si può provare a fare clic sul link sottostante.',
+    'auto_init_starting_desc' => 'Stiamo contattando il tuo sistema di autenticazione per avviare il processo di login. Se non ci sono progressi dopo 5 secondi, puoi provare a cliccare sul link qui sotto.',
     'auto_init_start_link' => 'Procedi con l\'autenticazione',
 
     // Password Reset
-    'reset_password' => 'Reimposta Password',
-    'reset_password_send_instructions' => 'Inserisci il tuo indirizzo sotto e ti verrà inviata una mail contenente un link per resettare la tua password.',
-    'reset_password_send_button' => 'Invia Link Reset',
-    'reset_password_sent' => 'Un link di reset della password verrà inviato a :email se la mail verrà trovata nel sistema.',
-    'reset_password_success' => 'La tua password è stata resettata correttamente.',
+    'reset_password' => 'Reimposta la password',
+    'reset_password_send_instructions' => 'Inserisci il tuo indirizzo e ti verrà inviata una mail contenente un link per reimpostare la tua password.',
+    'reset_password_send_button' => 'Invia link di ripristino',
+    'reset_password_sent' => 'Se la mail verrà trovata nel sistema, verrà inviato a :email un link di ripristino della password.',
+    'reset_password_success' => 'La tua password è stata ripristinata correttamente.',
     'email_reset_subject' => 'Reimposta la password di :appName',
-    'email_reset_text' => 'Stai ricevendo questa mail perché abbiamo ricevuto una richiesta di reset della password per il tuo account.',
-    'email_reset_not_requested' => 'Se non hai richiesto un reset della password, ignora questa mail.',
+    'email_reset_text' => 'Stai ricevendo questa mail perché abbiamo ricevuto una richiesta di ripristino della password per il tuo account.',
+    'email_reset_not_requested' => 'Se non hai richiesto un ripristino della password, ignora questa mail.',
 
     // Email Confirmation
     'email_confirm_subject' => 'Conferma email per :appName',
     'email_confirm_greeting' => 'Grazie per esserti registrato a :appName!',
     'email_confirm_text' => 'Conferma il tuo indirizzo email cliccando il pulsante sotto:',
-    'email_confirm_action' => 'Conferma Email',
+    'email_confirm_action' => 'Conferma email',
     'email_confirm_send_error' => 'La conferma della mail è richiesta ma non è stato possibile mandare la mail. Contatta l\'amministratore.',
     'email_confirm_success' => 'La tua email è stata confermata! Ora dovresti essere in grado di effettuare il login utilizzando questo indirizzo email.',
     'email_confirm_resent' => 'Mail di conferma reinviata, controlla la tua posta.',
     'email_confirm_thanks' => 'Grazie per la conferma!',
-    'email_confirm_thanks_desc' => 'Attendere un momento mentre la conferma viene gestita. Se non si è reindirizzati dopo 3 secondi, premere il link "Continua" qui sotto per procedere.',
+    'email_confirm_thanks_desc' => 'Attendi un momento mentre la conferma viene gestita. Se non vieni reindirizzato dopo 3 secondi, clicca sul link "Continua" qui sotto per procedere.',
 
-    'email_not_confirmed' => 'Indirizzo Email Non Confermato',
+    'email_not_confirmed' => 'Indirizzo email non confermato',
     'email_not_confirmed_text' => 'Il tuo indirizzo email non è ancora stato confermato.',
     'email_not_confirmed_click_link' => 'Clicca il link nella mail mandata subito dopo la tua registrazione.',
     'email_not_confirmed_resend' => 'Se non riesci a trovare la mail puoi rimandarla cliccando il pulsante sotto.',
-    'email_not_confirmed_resend_button' => 'Reinvia Conferma',
+    'email_not_confirmed_resend_button' => 'Reinvia conferma',
 
     // User Invite
     'user_invite_email_subject' => 'Sei stato invitato a unirti a :appName!',
     'user_invite_email_greeting' => 'Un account è stato creato per te su :appName.',
     'user_invite_email_text' => 'Clicca sul pulsante qui sotto per impostare una password e ottenere l\'accesso:',
-    'user_invite_email_action' => 'Imposta Password',
+    'user_invite_email_action' => 'Imposta password',
     'user_invite_page_welcome' => 'Benvenuto in :appName!',
-    'user_invite_page_text' => 'Per completare il tuo account e ottenere l\'accesso devi impostare una password che verrà utilizzata per accedere a :appName in futuro.',
-    'user_invite_page_confirm_button' => 'Conferma Password',
+    'user_invite_page_text' => 'Per completare il tuo account e ottenere l\'accesso, devi impostare una password che verrà utilizzata per accedere a :appName in futuro.',
+    'user_invite_page_confirm_button' => 'Conferma password',
     'user_invite_success_login' => 'Password impostata, ora dovresti essere in grado di effettuare il login utilizzando la password impostata per accedere a :appName!',
 
     // Multi-factor Authentication
-    'mfa_setup' => 'Imposta Autenticazione Multi-Fattore',
+    'mfa_setup' => 'Imposta autenticazione multi-fattore',
     'mfa_setup_desc' => 'Imposta l\'autenticazione multi-fattore come misura di sicurezza aggiuntiva per il tuo account.',
     'mfa_setup_configured' => 'Già configurata',
     'mfa_setup_reconfigure' => 'Riconfigura',
@@ -97,7 +97,7 @@ return [
     'mfa_gen_backup_codes_desc' => 'Conserva l\'elenco di codici qui sotto in un luogo sicuro. Quando accedi al sistema potrai utilizzare uno dei codici come meccanismo di autenticazione secondario.',
     'mfa_gen_backup_codes_download' => 'Scarica codici',
     'mfa_gen_backup_codes_usage_warning' => 'Ogni codice può essere utilizzato solo una volta',
-    'mfa_gen_totp_title' => 'Impostazione App Mobile',
+    'mfa_gen_totp_title' => 'Impostazione app mobile',
     'mfa_gen_totp_desc' => 'Per utilizzare l\'autenticazione multi-fattore avrai bisogno di un\'applicazione mobile che supporti TOTP come Google Authenticator, Authy o Microsoft Authenticator.',
     'mfa_gen_totp_scan' => 'Scansiona il codice QR qui sotto utilizzando la tua app di autenticazione preferita per iniziare.',
     'mfa_gen_totp_verify_setup' => 'Verifica configurazione',
index 113ddb0140566149b0e1e95a0a1ecc080ee9e264..d45f93c5e421727b13f65ba62f9b0fbf644271c7 100644 (file)
@@ -31,7 +31,7 @@ return [
     'update' => 'Aggiorna',
     'edit' => 'Modifica',
     'sort' => 'Ordina',
-    'move' => 'Muovi',
+    'move' => 'Sposta',
     'copy' => 'Copia',
     'reply' => 'Rispondi',
     'delete' => 'Elimina',
@@ -50,31 +50,31 @@ return [
     'previous' => 'Precedente',
     'filter_active' => 'Filtro attivo:',
     'filter_clear' => 'Pulisci filtro',
-    'download' => 'Download',
+    'download' => 'Scarica',
     'open_in_tab' => 'Apri nella scheda',
     'open' => 'Apri',
 
     // Sort Options
-    'sort_options' => 'Opzioni Ordinamento',
+    'sort_options' => 'Opzioni ordinamento',
     'sort_direction_toggle' => 'Inverti direzione ordinamento',
-    'sort_ascending' => 'Ordine ascendente',
-    'sort_descending' => 'Ordine discendente',
+    'sort_ascending' => 'Ordine crescente',
+    'sort_descending' => 'Ordine decrescente',
     'sort_name' => 'Nome',
     'sort_default' => 'Predefinito',
-    'sort_created_at' => 'Data Creazione',
-    'sort_updated_at' => 'Data Aggiornamento',
+    'sort_created_at' => 'Data creazione',
+    'sort_updated_at' => 'Data aggiornamento',
 
     // Misc
-    'deleted_user' => 'Utente Eliminato',
+    'deleted_user' => 'Utente eliminato',
     'no_activity' => 'Nessuna attività da mostrare',
     'no_items' => 'Nessun elemento disponibile',
     'back_to_top' => 'Torna in alto',
     'skip_to_main_content' => 'Passa al contenuto principale',
-    'toggle_details' => 'Mostra Dettagli',
-    'toggle_thumbnails' => 'Mostra Miniature',
+    'toggle_details' => 'Mostra dettagli',
+    'toggle_thumbnails' => 'Mostra miniature',
     'details' => 'Dettagli',
-    'grid_view' => 'Visualizzazione Griglia',
-    'list_view' => 'Visualizzazione Lista',
+    'grid_view' => 'Visualizzazione griglia',
+    'list_view' => 'Visualizzazione lista',
     'default' => 'Predefinito',
     'breadcrumb' => 'Navigazione',
     'status' => 'Stato',
@@ -85,29 +85,29 @@ return [
 
     // Header
     'homepage' => 'Homepage',
-    'header_menu_expand' => 'Espandi Menù Intestazione',
+    'header_menu_expand' => 'Espandi menù intestazione',
     'profile_menu' => 'Menù del profilo',
-    'view_profile' => 'Visualizza Profilo',
-    'edit_profile' => 'Modifica Profilo',
-    'dark_mode' => 'Modalità Scura',
-    'light_mode' => 'Modalità Chiara',
-    'global_search' => 'Ricerca Globale',
+    'view_profile' => 'Visualizza profilo',
+    'edit_profile' => 'Modifica profilo',
+    'dark_mode' => 'Modalità scura',
+    'light_mode' => 'Modalità chiara',
+    'global_search' => 'Ricerca globale',
 
     // Layout tabs
-    'tab_info' => 'Info',
-    'tab_info_label' => 'Tab: Mostra Informazioni Secondarie',
+    'tab_info' => 'Informazioni',
+    'tab_info_label' => 'Scheda: Mostra informazioni secondarie',
     'tab_content' => 'Contenuto',
-    'tab_content_label' => 'Tab: Mostra Contenuto Principale',
+    'tab_content_label' => 'Scheda: Mostra contenuto principale',
 
     // Email Content
-    'email_action_help' => 'Se hai problemi nel cliccare il pulsante ":actionText", copia e incolla lo URL sotto nel tuo browser:',
+    'email_action_help' => 'Se hai problemi nel cliccare il pulsante ":actionText", copia e incolla l\'URL qui sotto nel tuo browser:',
     'email_rights' => 'Tutti i diritti riservati',
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
     'privacy_policy' => 'Norme sulla privacy',
-    'terms_of_service' => 'Condizioni del Servizio',
+    'terms_of_service' => 'Condizioni del servizio',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Cerca :appName',
 ];
index b336893b5d2324ed590d1dddb58c9eef0e96db35..e067db7033ac56e4cc70f550b43fdf2677c47fc9 100644 (file)
@@ -5,10 +5,10 @@
 return [
 
     // Image Manager
-    'image_select' => 'Selezione Immagine',
-    'image_list' => 'Elenco Immagini',
-    'image_details' => 'Dettagli Immagine',
-    'image_upload' => 'Carica Immagine',
+    'image_select' => 'Seleziona un\'immagine',
+    'image_list' => 'Elenco immagini',
+    'image_details' => 'Dettagli immagine',
+    'image_upload' => 'Carica immagine',
     'image_intro' => 'Qui è possibile selezionare e gestire le immagini che sono state precedentemente caricate nel sistema.',
     'image_intro_upload' => 'Carica una nuova immagine trascinando un file immagine in questa finestra oppure utilizzando il pulsante "Carica immagine" in alto.',
     'image_all' => 'Tutte',
@@ -20,27 +20,27 @@ return [
     'image_uploaded_by' => 'Caricato da :userName',
     'image_uploaded_to' => 'Caricato su :pageLink',
     'image_updated' => 'Aggiornato il :updateDate',
-    'image_load_more' => 'Carica Altre',
-    'image_image_name' => 'Nome Immagine',
+    'image_load_more' => 'Carica altre',
+    'image_image_name' => 'Nome immagine',
     'image_delete_used' => 'Questa immagine è usata nelle pagine elencate.',
     'image_delete_confirm_text' => 'Sei sicuro di voler eliminare questa immagine?',
-    'image_select_image' => 'Seleziona Immagine',
+    'image_select_image' => 'Seleziona immagine',
     'image_dropzone' => 'Rilascia immagini o clicca qui per caricarle',
     'image_dropzone_drop' => 'Trascina qui le immagini da caricare',
-    'images_deleted' => 'Immagini Eliminate',
-    'image_preview' => 'Anteprima Immagine',
+    'images_deleted' => 'Immagini eliminate',
+    'image_preview' => 'Anteprima immagine',
     'image_upload_success' => 'Immagine caricata correttamente',
     'image_update_success' => 'Dettagli immagine aggiornati correttamente',
     'image_delete_success' => 'Immagine eliminata correttamente',
-    'image_replace' => 'Sostituisci Immagine',
+    'image_replace' => 'Sostituisci immagine',
     'image_replace_success' => 'File immagine aggiornato con successo',
-    'image_rebuild_thumbs' => 'Rigenera Variazioni Dimensione',
+    'image_rebuild_thumbs' => 'Rigenera variazioni dimensione',
     'image_rebuild_thumbs_success' => 'Variazioni di dimensione immagine ricostruite con successo!',
 
     // Code Editor
-    'code_editor' => 'Modifica Codice',
-    'code_language' => 'Linguaggio Codice',
-    'code_content' => 'Contenuto Codice',
-    'code_session_history' => 'Cronologia Sessione',
-    'code_save' => 'Salva Codice',
+    'code_editor' => 'Modifica codice',
+    'code_language' => 'Linguaggio codice',
+    'code_content' => 'Contenuto codice',
+    'code_session_history' => 'Cronologia sessione',
+    'code_save' => 'Salva codice',
 ];
index dc05fcb4314de2f7c19c128bf7f8575af36ae1f9..f9a6e47ae03ba7a2fb56a92632053ed3f2719f7b 100644 (file)
@@ -28,22 +28,22 @@ return [
 
     // Toolbar
     'formats' => 'Formati',
-    'header_large' => 'Intestazione Grande',
-    'header_medium' => 'Intestazione Media',
-    'header_small' => 'Intestazione Piccola',
-    'header_tiny' => 'Intestazione Minuscola',
+    'header_large' => 'Intestazione grande',
+    'header_medium' => 'Intestazione media',
+    'header_small' => 'Intestazione piccola',
+    'header_tiny' => 'Intestazione minuscola',
     'paragraph' => 'Paragrafo',
     'blockquote' => 'Virgolettato',
     'inline_code' => 'Codice in linea',
     'callouts' => 'Didascalie',
-    'callout_information' => 'Informazioni',
+    'callout_information' => 'Informazione',
     'callout_success' => 'Fatto',
-    'callout_warning' => 'Attenzione',
+    'callout_warning' => 'Avviso',
     'callout_danger' => 'Pericolo',
     'bold' => 'Grassetto',
     'italic' => 'Corsivo',
     'underline' => 'Sottolineato',
-    'strikethrough' => 'Testo barrato',
+    'strikethrough' => 'Barrato',
     'superscript' => 'Apice',
     'subscript' => 'Pedice',
     'text_color' => 'Colore del testo',
@@ -61,20 +61,20 @@ return [
     'indent_decrease' => 'Riduci rientro',
     'table' => 'Tabella',
     'insert_image' => 'Inserisci immagine',
-    'insert_image_title' => 'Inserisci/Modifica Immagine',
-    'insert_link' => 'Inserisci/Modifica Collegamento',
-    'insert_link_title' => 'Inserisci/Modifica Collegamento',
-    'insert_horizontal_line' => 'Inserisci Riga Orizzontale',
+    'insert_image_title' => 'Inserisci/modifica immagine',
+    'insert_link' => 'Inserisci/modifica collegamento',
+    'insert_link_title' => 'Inserisci/modifica collegamento',
+    'insert_horizontal_line' => 'Inserisci riga orizzontale',
     'insert_code_block' => 'Inserisci blocco di codice',
     'edit_code_block' => 'Modifica blocco di codice',
-    'insert_drawing' => 'Inserisci/Modifica Disegno',
+    'insert_drawing' => 'Inserisci/modifica disegno',
     'drawing_manager' => 'Gestore disegni',
     'insert_media' => 'Inserisci/modifica media',
-    'insert_media_title' => 'Inserisci/Modifica Media',
+    'insert_media_title' => 'Inserisci/modifica media',
     'clear_formatting' => 'Cancella formattazione',
     'source_code' => 'Codice sorgente',
-    'source_code_title' => 'Codice Sorgente',
-    'fullscreen' => 'Schermo Intero',
+    'source_code_title' => 'Codice sorgente',
+    'fullscreen' => 'Schermo intero',
     'image_options' => 'Opzioni immagine',
 
     // Tables
@@ -92,39 +92,39 @@ return [
     'delete_column' => 'Elimina colonna',
     'table_cell' => 'Cella',
     'table_row' => 'Riga',
-    'table_column' => 'Column',
+    'table_column' => 'Colonna',
     'cell_properties' => 'Proprietà cella',
-    'cell_properties_title' => 'Proprietà Cella',
+    'cell_properties_title' => 'Proprietà cella',
     'cell_type' => 'Tipo di cella',
     'cell_type_cell' => 'Cella',
     'cell_scope' => 'Ambito',
     'cell_type_header' => 'Cella intestazione',
     'merge_cells' => 'Unisci celle',
     'split_cell' => 'Dividi cella',
-    'table_row_group' => 'Gruppo Riga',
-    'table_column_group' => 'Gruppo Colonna',
+    'table_row_group' => 'Gruppo riga',
+    'table_column_group' => 'Gruppo colonna',
     'horizontal_align' => 'Allineamento orizzontale',
     'vertical_align' => 'Allineamento verticale',
     'border_width' => 'Spessore bordo',
-    'border_style' => 'Stile del Bordo',
-    'border_color' => 'Colore del bordo',
+    'border_style' => 'Stile bordo',
+    'border_color' => 'Colore bordo',
     'row_properties' => 'Proprietà riga',
-    'row_properties_title' => 'Proprietà Riga',
+    'row_properties_title' => 'Proprietà riga',
     'cut_row' => 'Taglia riga',
     'copy_row' => 'Copia riga',
-    'paste_row_before' => 'Inserisci la riga prima',
+    'paste_row_before' => 'Incolla riga prima',
     'paste_row_after' => 'Incolla riga dopo',
-    'row_type' => 'Tipo di riga',
+    'row_type' => 'Tipo riga',
     'row_type_header' => 'Intestazione',
     'row_type_body' => 'Corpo',
-    'row_type_footer' => 'Piè di Pagina',
+    'row_type_footer' => 'Piè di pagina',
     'alignment' => 'Allineamento',
     'cut_column' => 'Taglia colonna',
     'copy_column' => 'Copia colonna',
     'paste_column_before' => 'Incolla colonna prima',
-    'paste_column_after' => 'Inserisci colonna dopo',
-    'cell_padding' => 'Spaziatura cella',
-    'cell_spacing' => 'Spaziatura celle',
+    'paste_column_after' => 'Incolla colonna dopo',
+    'cell_padding' => 'Padding cella',
+    'cell_spacing' => 'Spaziatura cella',
     'caption' => 'Didascalia',
     'show_caption' => 'Mostra didascalia',
     'constrain' => 'Mantieni proporzioni',
@@ -132,10 +132,10 @@ return [
     'cell_border_dotted' => 'Punteggiato',
     'cell_border_dashed' => 'Tratteggiato',
     'cell_border_double' => 'Doppio',
-    'cell_border_groove' => 'Bordo Incassato',
-    'cell_border_ridge' => 'Bordo In Rilievo',
+    'cell_border_groove' => 'Bordo incassato',
+    'cell_border_ridge' => 'In rilievo',
     'cell_border_inset' => 'Sfondo Incassato',
-    'cell_border_outset' => 'Sfondo In rilievo',
+    'cell_border_outset' => 'Sfondo in rilievo',
     'cell_border_none' => 'Nessuno',
     'cell_border_hidden' => 'Nascosto',
 
@@ -155,17 +155,17 @@ return [
     'insert_collapsible' => 'Inserisci blocco collassabile',
     'collapsible_unwrap' => 'Espandi',
     'edit_label' => 'Modifica etichetta',
-    'toggle_open_closed' => 'Espandi/Comprimi',
+    'toggle_open_closed' => 'Espandi/comprimi',
     'collapsible_edit' => 'Modifica blocco collassabile',
-    'toggle_label' => 'Attiva/Disattiva etichetta',
+    'toggle_label' => 'Attiva/disattiva etichetta',
 
     // About view
     'about' => 'Informazioni sull\'editor',
     'about_title' => 'Informazioni sull\'editor di WYSIWYG',
-    'editor_license' => 'Licenza & Copyright Dell\'Editor',
+    'editor_license' => 'Licenza e copyright dell\'editor',
     'editor_tiny_license' => 'Questo editor è realizzato usando :tinyLink che è fornito sotto la licenza MIT.',
     'editor_tiny_license_link' => 'I dettagli del copyright e della licenza di TinyMCE sono disponibili qui.',
-    'save_continue' => 'Salva Pagina E Continua',
+    'save_continue' => 'Salva pagina e continua',
     'callouts_cycle' => '(Continua a premere per passare da un tipo all\'altro)',
     'link_selector' => 'Link al contenuto',
     'shortcuts' => 'Scorciatoie',
index 83ea85d266ffa58d045982a9873eb8ab625ce1ad..d7542b1ba1146d3b3d29d00f61541834b58d148b 100644 (file)
@@ -11,36 +11,36 @@ return [
     'recently_updated_pages' => 'Pagine aggiornate di recente',
     'recently_created_chapters' => 'Capitoli creati di recente',
     'recently_created_books' => 'Libri creati di recente',
-    'recently_created_shelves' => 'Librerie Create Di Recente',
+    'recently_created_shelves' => 'Librerie create di recente',
     'recently_update' => 'Aggiornati di recente',
     'recently_viewed' => 'Visti di recente',
-    'recent_activity' => 'Attività Recente',
+    'recent_activity' => 'Attività recente',
     'create_now' => 'Creane uno ora',
-    'revisions' => 'Versioni',
-    'meta_revision' => 'Versione #:revisionCount',
+    'revisions' => 'Revisioni',
+    'meta_revision' => 'Revisione #:revisionCount',
     'meta_created' => 'Creato :timeLength',
     'meta_created_name' => 'Creato :timeLength da :user',
     'meta_updated' => 'Aggiornato :timeLength',
     'meta_updated_name' => 'Aggiornato :timeLength da :user',
     'meta_owned_name' => 'Creati da :user',
     'meta_reference_count' => 'Referenziato da :count item|Referenziato da :count items',
-    'entity_select' => 'Selezione Entità',
+    'entity_select' => 'Seleziona l\'entità',
     'entity_select_lack_permission' => 'Non hai i permessi necessari per selezionare questo elemento',
     'images' => 'Immagini',
-    'my_recent_drafts' => 'Bozze Recenti',
+    'my_recent_drafts' => 'Bozze recenti',
     'my_recently_viewed' => 'Visti di recente',
-    'my_most_viewed_favourites' => 'I Miei Preferiti Più Visti',
-    'my_favourites' => 'I miei Preferiti',
+    'my_most_viewed_favourites' => 'I miei preferiti più visti',
+    'my_favourites' => 'I miei preferiti',
     'no_pages_viewed' => 'Non hai visto nessuna pagina',
-    'no_pages_recently_created' => 'Nessuna pagina è stata creata di recente',
-    'no_pages_recently_updated' => 'Nessuna pagina è stata aggiornata di recente',
+    'no_pages_recently_created' => 'Nessuna pagina creata di recente',
+    'no_pages_recently_updated' => 'Nessuna pagina aggiornata di recente',
     'export' => 'Esporta',
-    'export_html' => 'File Contenuto Web',
+    'export_html' => 'File contenuto web',
     'export_pdf' => 'File PDF',
     'export_text' => 'File di testo',
     'export_md' => 'File Markdown',
-    'default_template' => 'Modello di Pagina Predefinito',
-    'default_template_explain' => 'Assegna un modello di pagina che sarà usato come contenuto predefinito per tutte le pagine create in questo elemento. Tieni presente che questo verrà utilizzato solo se il creatore della pagina ha accesso alla pagina del modello scelto.',
+    'default_template' => 'Modello di pagina predefinito',
+    'default_template_explain' => 'Assegna un modello di pagina che sarà usato come contenuto predefinito per tutte le pagine create in questo elemento. Tieni presente che potrà essere utilizzato solo se il creatore della pagina ha accesso alla pagina del modello scelto.',
     'default_template_select' => 'Seleziona una pagina modello',
 
     // Permissions and restrictions
@@ -48,25 +48,25 @@ return [
     'permissions_desc' => 'Imposta qui i permessi per sovrascrivere i permessi predefiniti forniti dai ruoli utente.',
     'permissions_book_cascade' => 'I permessi impostati sui libri si trasmettono automaticamente a cascata ai capitoli e alle pagine figli, a meno che non siano stati definiti permessi propri.',
     'permissions_chapter_cascade' => 'I permessi impostati sui capitoli si trasmettono automaticamente a cascata alle pagine figlie, a meno che non siano stati definiti permessi propri.',
-    'permissions_save' => 'Salva Permessi',
+    'permissions_save' => 'Salva permessi',
     'permissions_owner' => 'Proprietario',
-    'permissions_role_everyone_else' => 'Tutti Gli Altri',
+    'permissions_role_everyone_else' => 'Tutti gli altri',
     'permissions_role_everyone_else_desc' => 'Imposta i permessi per tutti i ruoli non specificamente sovrascritti.',
     'permissions_role_override' => 'Sovrascrivere i permessi per il ruolo',
     'permissions_inherit_defaults' => 'Eredita predefinite',
 
     // Search
-    'search_results' => 'Risultati Ricerca',
+    'search_results' => 'Risultati della ricerca',
     'search_total_results_found' => ':count risultato trovato|:count risultati trovati',
-    'search_clear' => 'Pulisci Ricerca',
+    'search_clear' => 'Pulisci ricerca',
     'search_no_pages' => 'Nessuna pagina corrisponde alla ricerca',
     'search_for_term' => 'Ricerca per :term',
-    'search_more' => 'Più risultati',
-    'search_advanced' => 'Ricerca Avanzata',
-    'search_terms' => 'Termini Ricerca',
-    'search_content_type' => 'Tipo di Contenuto',
-    'search_exact_matches' => 'Corrispondenza Esatta',
-    'search_tags' => 'Ricerche Tag',
+    'search_more' => 'Altri risultati',
+    'search_advanced' => 'Ricerca avanzata',
+    'search_terms' => 'Termini di ricerca',
+    'search_content_type' => 'Tipo di contenuto',
+    'search_exact_matches' => 'Corrispondenza esatta',
+    'search_tags' => 'Ricerche per tag',
     'search_options' => 'Opzioni',
     'search_viewed_by_me' => 'Visti da me',
     'search_not_viewed_by_me' => 'Non visti da me',
@@ -74,68 +74,68 @@ return [
     'search_created_by_me' => 'Creati da me',
     'search_updated_by_me' => 'Aggiornati da me',
     'search_owned_by_me' => 'Creati da me',
-    'search_date_options' => 'Opzioni Data',
+    'search_date_options' => 'Opzioni data',
     'search_updated_before' => 'Aggiornati prima del',
     'search_updated_after' => 'Aggiornati dopo il',
     'search_created_before' => 'Creati prima del',
     'search_created_after' => 'Creati dopo il',
-    'search_set_date' => 'Imposta Data',
-    'search_update' => 'Aggiorna Ricerca',
+    'search_set_date' => 'Imposta data',
+    'search_update' => 'Aggiorna ricerca',
 
     // Shelves
     'shelf' => 'Libreria',
     'shelves' => 'Librerie',
-    'x_shelves' => ':count Libreria|:count Librerie',
-    'shelves_empty' => 'Nessuna libreria è stata creata',
-    'shelves_create' => 'Crea Nuova Libreria',
-    'shelves_popular' => 'Librerie Popolari',
-    'shelves_new' => 'Nuove Librerie',
-    'shelves_new_action' => 'Nuova Libreria',
+    'x_shelves' => ':count libreria|:count librerie',
+    'shelves_empty' => 'Nessuna libreria creata',
+    'shelves_create' => 'Crea nuova libreria',
+    'shelves_popular' => 'Librerie popolari',
+    'shelves_new' => 'Nuove librerie',
+    'shelves_new_action' => 'Nuova libreria',
     'shelves_popular_empty' => 'Le librerie più popolari appariranno qui.',
     'shelves_new_empty' => 'Le librerie create più di recente appariranno qui.',
-    'shelves_save' => 'Salva Libreria',
+    'shelves_save' => 'Salva libreria',
     'shelves_books' => 'Libri in questa libreria',
     'shelves_add_books' => 'Aggiungi libri a questa libreria',
     'shelves_drag_books' => 'Trascina i libri qui sotto per aggiungerli a questa libreria',
     'shelves_empty_contents' => 'Questa libreria non ha libri assegnati',
     'shelves_edit_and_assign' => 'Modifica la libreria per assegnare i libri',
-    'shelves_edit_named' => 'Modifica Libreria :name',
-    'shelves_edit' => 'Modifica Libreria',
-    'shelves_delete' => 'Elimina Libreria',
-    'shelves_delete_named' => 'Elimina Libreria :name',
+    'shelves_edit_named' => 'Modifica libreria :name',
+    'shelves_edit' => 'Modifica libreria',
+    'shelves_delete' => 'Elimina libreria',
+    'shelves_delete_named' => 'Elimina libreria :name',
     'shelves_delete_explain' => "La libreria ':name' verrà eliminata. I libri al suo interno non verranno eliminati.",
     'shelves_delete_confirmation' => 'Sei sicuro di voler eliminare questa libreria?',
-    'shelves_permissions' => 'Permessi Libreria',
-    'shelves_permissions_updated' => 'Permessi Libreria Aggiornati',
-    'shelves_permissions_active' => 'Permessi Libreria Attivi',
+    'shelves_permissions' => 'Permessi libreria',
+    'shelves_permissions_updated' => 'Permessi libreria aggiornati',
+    'shelves_permissions_active' => 'Permessi libreria attivi',
     'shelves_permissions_cascade_warning' => 'I permessi delle librerie non si estendono automaticamente ai libri contenuti. Questo perché un libro può essere presente su più scaffali. I permessi possono comunque essere copiati ai libri al suo interno usando l\'opzione sottostante.',
     'shelves_permissions_create' => 'Le autorizzazioni per la creazione di librerie sono utilizzate solo per copiare le autorizzazioni ai libri figli utilizzando l\'azione sottostante. Non controllano la capacità di creare libri.',
-    'shelves_copy_permissions_to_books' => 'Copia Permessi ai Libri',
-    'shelves_copy_permissions' => 'Copia Permessi',
+    'shelves_copy_permissions_to_books' => 'Copia permessi ai libri',
+    'shelves_copy_permissions' => 'Copia permessi',
     'shelves_copy_permissions_explain' => 'Verranno applicati tutti i permessi della libreria ai libri al suo interno. Prima dell\'attivazione, assicurati di aver salvato le modifiche ai permessi di questa libreria.',
     'shelves_copy_permission_success' => 'Permessi della libreria copiati in :count libri',
 
     // Books
     'book' => 'Libro',
     'books' => 'Libri',
-    'x_books' => ':count Libro|:count Libri',
-    'books_empty' => 'Nessun libro è stato creato',
-    'books_popular' => 'Libri Popolari',
-    'books_recent' => 'Libri Recenti',
-    'books_new' => 'Nuovi Libri',
-    'books_new_action' => 'Nuovo Libro',
+    'x_books' => ':count libro|:count libri',
+    'books_empty' => 'Nessun libro creato',
+    'books_popular' => 'Libri popolari',
+    'books_recent' => 'Libri recenti',
+    'books_new' => 'Nuovi libri',
+    'books_new_action' => 'Nuovo libro',
     'books_popular_empty' => 'I libri più popolari appariranno qui.',
-    'books_new_empty' => 'I libri creati più di recente appariranno qui.',
-    'books_create' => 'Crea Nuovo Libro',
-    'books_delete' => 'Elimina Libro',
+    'books_new_empty' => 'I libri creati di recente appariranno qui.',
+    'books_create' => 'Crea nuovo libro',
+    'books_delete' => 'Elimina libro',
     'books_delete_named' => 'Elimina il libro :bookName',
     'books_delete_explain' => 'Questo eliminerà il libro di nome \':bookName\'. Tutte le pagine e i capitoli saranno rimossi.',
     'books_delete_confirmation' => 'Sei sicuro di voler eliminare questo libro?',
-    'books_edit' => 'Modifica Libro',
+    'books_edit' => 'Modifica libro',
     'books_edit_named' => 'Modifica il libro :bookName',
-    'books_form_book_name' => 'Nome Libro',
-    'books_save' => 'Salva Libro',
-    'books_permissions' => 'Permessi Libro',
+    'books_form_book_name' => 'Nome libro',
+    'books_save' => 'Salva libro',
+    'books_permissions' => 'Permessi libro',
     'books_permissions_updated' => 'Permessi del libro aggiornati',
     'books_empty_contents' => 'Non ci sono pagine o capitoli per questo libro.',
     'books_empty_create_page' => 'Crea una nuova pagina',
@@ -143,16 +143,16 @@ return [
     'books_empty_add_chapter' => 'Aggiungi un capitolo',
     'books_permissions_active' => 'Permessi libro attivi',
     'books_search_this' => 'Cerca in questo libro',
-    'books_navigation' => 'Navigazione Libro',
+    'books_navigation' => 'Navigazione libro',
     'books_sort' => 'Ordina il contenuto del libro',
     'books_sort_desc' => 'Sposta capitoli e pagine all\'interno di un libro per riorganizzarne il contenuto. È possibile aggiungere altri libri, per spostare facilmente capitoli e pagine da un libro all\'altro.',
     'books_sort_named' => 'Ordina il libro :bookName',
     'books_sort_name' => 'Ordina per Nome',
-    'books_sort_created' => 'Ordina per Data di Creazione',
-    'books_sort_updated' => 'Ordina per Data di Aggiornamento',
-    'books_sort_chapters_first' => 'Capitoli Per Primi',
-    'books_sort_chapters_last' => 'Capitoli Per Ultimi',
-    'books_sort_show_other' => 'Mostra Altri Libri',
+    'books_sort_created' => 'Ordina per Data di creazione',
+    'books_sort_updated' => 'Ordina per Data di aggiornamento',
+    'books_sort_chapters_first' => 'Capitoli per primi',
+    'books_sort_chapters_last' => 'Capitoli per ultimi',
+    'books_sort_show_other' => 'Mostra altri libri',
     'books_sort_save' => 'Salva il nuovo ordine',
     'books_sort_show_other_desc' => 'Aggiungi qui altri libri per includerli nell\'operazione di ordinamento e consentire una facile riorganizzazione incrociata dei libri.',
     'books_sort_move_up' => 'Muovi su',
@@ -165,126 +165,126 @@ return [
     'books_sort_move_book_end' => 'Passa alla fine del libro',
     'books_sort_move_before_chapter' => 'Passa al capitolo precedente',
     'books_sort_move_after_chapter' => 'Passa al capitolo successivo',
-    'books_copy' => 'Copia Libro',
+    'books_copy' => 'Copia libro',
     'books_copy_success' => 'Libro copiato con successo',
 
     // Chapters
     'chapter' => 'Capitolo',
     'chapters' => 'Capitoli',
-    'x_chapters' => ':count Capitolo|:count Capitoli',
-    'chapters_popular' => 'Capitoli Popolari',
-    'chapters_new' => 'Nuovo Capitolo',
+    'x_chapters' => ':count capitolo|:count capitoli',
+    'chapters_popular' => 'Capitoli popolari',
+    'chapters_new' => 'Nuovo capitolo',
     'chapters_create' => 'Crea un nuovo capitolo',
-    'chapters_delete' => 'Elimina Capitolo',
+    'chapters_delete' => 'Elimina capitolo',
     'chapters_delete_named' => 'Elimina il capitolo :chapterName',
-    'chapters_delete_explain' => 'Procedendo si eliminerà il capitolo denominato \':chapterName\'. Anche le pagine all\'interno saranno eliminate.',
+    'chapters_delete_explain' => 'Verrà eliminato il capitolo denominato \':chapterName\'. Saranno eliminate anche le pagine all\'interno.',
     'chapters_delete_confirm' => 'Sei sicuro di voler eliminare questo capitolo?',
-    'chapters_edit' => 'Elimina Capitolo',
+    'chapters_edit' => 'Elimina capitolo',
     'chapters_edit_named' => 'Modifica il capitolo :chapterName',
-    'chapters_save' => 'Salva Capitolo',
-    'chapters_move' => 'Muovi Capitolo',
-    'chapters_move_named' => 'Muovi il capitolo :chapterName',
-    'chapters_copy' => 'Copia Capitolo',
+    'chapters_save' => 'Salva capitolo',
+    'chapters_move' => 'Sposta capitolo',
+    'chapters_move_named' => 'Sposta il capitolo :chapterName',
+    'chapters_copy' => 'Copia capitolo',
     'chapters_copy_success' => 'Capitolo copiato con successo',
-    'chapters_permissions' => 'Permessi Capitolo',
+    'chapters_permissions' => 'Permessi capitolo',
     'chapters_empty' => 'Non ci sono pagine in questo capitolo.',
-    'chapters_permissions_active' => 'Permessi Capitolo Attivi',
-    'chapters_permissions_success' => 'Permessi Capitolo Aggiornati',
+    'chapters_permissions_active' => 'Permessi capitolo attivi',
+    'chapters_permissions_success' => 'Permessi capitolo aggiornati',
     'chapters_search_this' => 'Cerca in questo capitolo',
-    'chapter_sort_book' => 'Ordina Libro',
+    'chapter_sort_book' => 'Ordina libro',
 
     // Pages
     'page' => 'Pagina',
     'pages' => 'Pagine',
-    'x_pages' => ':count Pagina|:count Pagine',
-    'pages_popular' => 'Pagine Popolari',
-    'pages_new' => 'Nuova Pagina',
+    'x_pages' => ':count pagina|:count pagine',
+    'pages_popular' => 'Pagine popolari',
+    'pages_new' => 'Nuova pagina',
     'pages_attachments' => 'Allegati',
-    'pages_navigation' => 'Navigazione Pagine',
-    'pages_delete' => 'Elimina Pagina',
+    'pages_navigation' => 'Navigazione pagine',
+    'pages_delete' => 'Elimina pagina',
     'pages_delete_named' => 'Elimina la pagina :pageName',
     'pages_delete_draft_named' => 'Elimina bozza della pagina :pageName',
-    'pages_delete_draft' => 'Elimina Bozza Pagina',
+    'pages_delete_draft' => 'Elimina bozza pagina',
     'pages_delete_success' => 'Pagina eliminata',
     'pages_delete_draft_success' => 'Bozza di una pagina eliminata',
     'pages_delete_warning_template' => 'Questa pagina è in uso come modello di pagina predefinito del libro o del capitolo. Questi libri o capitoli non avranno più un modello di pagina predefinito assegnato dopo che questa pagina sarà eliminata.',
     'pages_delete_confirm' => 'Sei sicuro di voler eliminare questa pagina?',
     'pages_delete_draft_confirm' => 'Sei sicuro di voler eliminare la bozza di questa pagina?',
     'pages_editing_named' => 'Modifica :pageName',
-    'pages_edit_draft_options' => 'Opzioni Bozza',
-    'pages_edit_save_draft' => 'Salva Bozza',
-    'pages_edit_draft' => 'Modifica Bozza della pagina',
-    'pages_editing_draft' => 'Modifica Bozza',
-    'pages_editing_page' => 'Modifica Pagina',
+    'pages_edit_draft_options' => 'Opzioni bozza',
+    'pages_edit_save_draft' => 'Salva bozza',
+    'pages_edit_draft' => 'Modifica bozza della pagina',
+    'pages_editing_draft' => 'Modifica bozza',
+    'pages_editing_page' => 'Modifica pagina',
     'pages_edit_draft_save_at' => 'Bozza salvata alle ',
-    'pages_edit_delete_draft' => 'Elimina Bozza',
+    'pages_edit_delete_draft' => 'Elimina bozza',
     'pages_edit_delete_draft_confirm' => 'Si è sicuri di voler eliminare le modifiche alla pagina bozza? Tutte le modifiche apportate dall\'ultimo salvataggio completo andranno perse e l\'editor verrà aggiornato con l\'ultimo stato di salvataggio della pagina non in bozza.',
-    'pages_edit_discard_draft' => 'Scarta Bozza',
+    'pages_edit_discard_draft' => 'Scarta bozza',
     'pages_edit_switch_to_markdown' => 'Passa all\'editor Markdown',
     'pages_edit_switch_to_markdown_clean' => '(Contenuto Chiaro)',
-    'pages_edit_switch_to_markdown_stable' => '(Contenuto Stabile)',
+    'pages_edit_switch_to_markdown_stable' => '(Contenuto stabile)',
     'pages_edit_switch_to_wysiwyg' => 'Passa all\'editor WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
-    'pages_edit_set_changelog' => 'Imposta Changelog',
+    'pages_edit_switch_to_new_wysiwyg' => 'Passa al nuovo WYSIWYG',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In test alpha)',
+    'pages_edit_set_changelog' => 'Imposta changelog',
     'pages_edit_enter_changelog_desc' => 'Inserisci una breve descrizione dei cambiamenti che hai apportato',
-    'pages_edit_enter_changelog' => 'Inserisci Changelog',
-    'pages_editor_switch_title' => 'Cambia Editor',
+    'pages_edit_enter_changelog' => 'Inserisci changelog',
+    'pages_editor_switch_title' => 'Cambia editor',
     'pages_editor_switch_are_you_sure' => 'Sei sicuro di voler cambiare l\'editor di questa pagina?',
     'pages_editor_switch_consider_following' => 'Considera quanto segue quando si cambia editor:',
     'pages_editor_switch_consideration_a' => 'Una volta salvata, la nuova opzione di editor sarà utilizzata da chi modificherà in futuro, inclusi quelli che potrebbero non essere in grado di cambiare il tipo di editor da soli.',
     'pages_editor_switch_consideration_b' => 'Ciò può potenzialmente portare a una perdita di dettagli e sintassi in determinate circostanze.',
-    'pages_editor_switch_consideration_c' => 'Le modifiche al tag o al changelog, fatte dall\'ultimo salvataggio, non persisteranno in questa modifica.',
-    'pages_save' => 'Salva Pagina',
-    'pages_title' => 'Titolo Pagina',
-    'pages_name' => 'Nome Pagina',
+    'pages_editor_switch_consideration_c' => 'Le modifiche ai tag o al changelog, fatte dall\'ultimo salvataggio, non persisteranno in questa modifica.',
+    'pages_save' => 'Salva pagina',
+    'pages_title' => 'Titolo pagina',
+    'pages_name' => 'Nome pagina',
     'pages_md_editor' => 'Editor',
     'pages_md_preview' => 'Anteprima',
-    'pages_md_insert_image' => 'Inserisci Immagina',
-    'pages_md_insert_link' => 'Inserisci Link Entità',
-    'pages_md_insert_drawing' => 'Inserisci Disegno',
+    'pages_md_insert_image' => 'Inserisci immagine',
+    'pages_md_insert_link' => 'Inserisci collegamento entità',
+    'pages_md_insert_drawing' => 'Inserisci disegno',
     'pages_md_show_preview' => 'Visualizza anteprima',
     'pages_md_sync_scroll' => 'Sincronizza scorrimento anteprima',
-    'pages_drawing_unsaved' => 'Trovato Disegno Non Salvato',
-    'pages_drawing_unsaved_confirm' => 'Sono stati trovati i dati di un disegno non salvati da un precedente tentativo di salvataggio di disegno non riuscito. Ripristinare e continuare a modificare questo disegno non salvato?',
+    'pages_drawing_unsaved' => 'Trovato disegno non salvato',
+    'pages_drawing_unsaved_confirm' => 'Sono stati trovati i dati di un disegno non salvato da un precedente tentativo di salvataggio di disegno non riuscito. Ripristinare e continuare a modificare questo disegno non salvato?',
     'pages_not_in_chapter' => 'La pagina non è in un capitolo',
-    'pages_move' => 'Muovi Pagina',
-    'pages_copy' => 'Copia Pagina',
-    'pages_copy_desination' => 'Copia Destinazione',
+    'pages_move' => 'Sposta pagina',
+    'pages_copy' => 'Copia pagina',
+    'pages_copy_desination' => 'Copia destinazione',
     'pages_copy_success' => 'Pagina copiata correttamente',
-    'pages_permissions' => 'Permessi Pagina',
+    'pages_permissions' => 'Permessi pagina',
     'pages_permissions_success' => 'Permessi pagina aggiornati',
     'pages_revision' => 'Versione',
-    'pages_revisions' => 'Versioni Pagina',
-    'pages_revisions_desc' => 'Di seguito sono elencate tutte le revisioni precedenti di questa pagina. È possibile consultare, confrontare e ripristinare le vecchie versioni della pagina, se le autorizzazioni lo consentono. La cronologia completa della pagina potrebbe non essere riportata qui, poiché, a seconda della configurazione del sistema, le vecchie revisioni potrebbero essere cancellate automaticamente.',
-    'pages_revisions_named' => 'Versioni della pagina :pageName',
-    'pages_revision_named' => 'Versione della pagina :pageName',
+    'pages_revisions' => 'Revisioni pagina',
+    'pages_revisions_desc' => 'Di seguito sono elencate tutte le revisioni precedenti di questa pagina. È possibile consultare, confrontare e ripristinare le vecchie versioni della pagina, se hai i permessi. La cronologia completa della pagina potrebbe non essere riportata qui, poiché a seconda della configurazione del sistema le vecchie revisioni potrebbero essere cancellate automaticamente.',
+    'pages_revisions_named' => 'Revisioni della pagina :pageName',
+    'pages_revision_named' => 'Revisione della pagina :pageName',
     'pages_revision_restored_from' => 'Ripristinato da #:id; :summary',
-    'pages_revisions_created_by' => 'Creata Da',
-    'pages_revisions_date' => 'Data Versione',
+    'pages_revisions_created_by' => 'Creata da',
+    'pages_revisions_date' => 'Data versione',
     'pages_revisions_number' => '#',
-    'pages_revisions_sort_number' => 'Numero Revisione',
+    'pages_revisions_sort_number' => 'Numero revisione',
     'pages_revisions_numbered' => 'Revisione #:id',
-    'pages_revisions_numbered_changes' => 'Modifiche Revisione #:id',
-    'pages_revisions_editor' => 'Tipo Di Editor',
-    'pages_revisions_changelog' => 'Cambiamenti',
+    'pages_revisions_numbered_changes' => 'Modifiche revisione #:id',
+    'pages_revisions_editor' => 'Tipo di editor',
+    'pages_revisions_changelog' => 'Changelog',
     'pages_revisions_changes' => 'Cambiamenti',
-    'pages_revisions_current' => 'Versione Corrente',
+    'pages_revisions_current' => 'Versione corrente',
     'pages_revisions_preview' => 'Anteprima',
     'pages_revisions_restore' => 'Ripristina',
-    'pages_revisions_none' => 'Questa pagina non ha versioni',
-    'pages_copy_link' => 'Copia Link',
+    'pages_revisions_none' => 'Questa pagina non ha revisioni',
+    'pages_copy_link' => 'Copia collegamento',
     'pages_edit_content_link' => 'Vai alla sezione nell\'editor',
-    'pages_pointer_enter_mode' => 'Accedi alla modalità di selezione della sezione',
-    'pages_pointer_label' => 'Opzioni Sezione Pagina',
-    'pages_pointer_permalink' => 'Permalink Sezione Pagina',
-    'pages_pointer_include_tag' => 'Sezione Pagina Includi Tag',
-    'pages_pointer_toggle_link' => 'Modalità Permalink, Premi per mostrare includi tag',
+    'pages_pointer_enter_mode' => 'Vai alla modalità di selezione della sezione',
+    'pages_pointer_label' => 'Opzioni sezione pagina',
+    'pages_pointer_permalink' => 'Sezione pagina Permalink',
+    'pages_pointer_include_tag' => 'Sezione pagina includi tag',
+    'pages_pointer_toggle_link' => 'Modalità Permalink, premi per mostrare includi tag',
     'pages_pointer_toggle_include' => 'Modalità includi tag, premi per mostrare permalink',
-    'pages_permissions_active' => 'Permessi Pagina Attivi',
+    'pages_permissions_active' => 'Permessi pagina attivi',
     'pages_initial_revision' => 'Pubblicazione iniziale',
     'pages_references_update_revision' => 'Aggiornamento automatico di sistema dei collegamenti interni',
-    'pages_initial_name' => 'Nuova Pagina',
+    'pages_initial_name' => 'Nuova pagina',
     'pages_editing_draft_notification' => 'Stai modificando una bozza che è stata salvata il :timeDiff.',
     'pages_draft_edited_notification' => 'Questa pagina è stata aggiornata. Si consiglia di scartare questa bozza.',
     'pages_draft_page_changed_since_creation' => 'Questa pagina è stata aggiornata da quando è stata creata questa bozza. Si consiglia di scartare questa bozza o di fare attenzione a non sovrascrivere alcun cambiamento alla pagina.',
@@ -297,15 +297,15 @@ return [
     ],
     'pages_draft_discarded' => 'Bozza scartata! L\'editor è stato aggiornato con il contenuto della pagina corrente',
     'pages_draft_deleted' => 'Bozza eliminata! L\'editor è stato aggiornato con il contenuto della pagina corrente',
-    'pages_specific' => 'Pagina Specifica',
-    'pages_is_template' => 'Template Pagina',
+    'pages_specific' => 'Pagina specifica',
+    'pages_is_template' => 'Modello pagina',
 
     // Editor Sidebar
     'toggle_sidebar' => 'Attiva/disattiva barra laterale',
-    'page_tags' => 'Tag Pagina',
-    'chapter_tags' => 'Tag Capitolo',
-    'book_tags' => 'Tag Libro',
-    'shelf_tags' => 'Tag Libreria',
+    'page_tags' => 'Tag pagina',
+    'chapter_tags' => 'Tag capitolo',
+    'book_tags' => 'Tag libro',
+    'shelf_tags' => 'Tag libreria',
     'tag' => 'Tag',
     'tags' =>  'Tag',
     'tags_index_desc' => 'I tag possono essere applicati ai contenuti del sistema per applicare una forma flessibile di categorizzazione. I tag possono avere una chiave e un valore, il valore è opzionale. Una volta applicati, i contenuti possono essere cercati utilizzando il nome e il valore del tag.',
@@ -314,7 +314,7 @@ return [
     'tags_explain' => "Aggiungi tag per categorizzare meglio il contenuto. \n Puoi assegnare un valore ai tag per una migliore organizzazione.",
     'tags_add' => 'Aggiungi un altro tag',
     'tags_remove' => 'Rimuovi questo tag',
-    'tags_usages' => 'Utilizzo totale dei tag',
+    'tags_usages' => 'Utilizzi totali dei tag',
     'tags_assigned_pages' => 'Assegnato alle pagine',
     'tags_assigned_chapters' => 'Assegnato ai capitoli',
     'tags_assigned_books' => 'Assegnato ai libri',
@@ -325,22 +325,22 @@ return [
     'tags_view_existing_tags' => 'Usa i tag esistenti',
     'tags_list_empty_hint' => 'I tag possono essere assegnati tramite la barra laterale dell\'editor di pagina o durante la modifica dei dettagli di un libro, di un capitolo o di una libreria.',
     'attachments' => 'Allegati',
-    'attachments_explain' => 'Carica alcuni file o allega link per visualizzarli nella pagina. Questi sono visibili nella barra laterale della pagina.',
+    'attachments_explain' => 'Carica alcuni file o allega dei collegamenti per visualizzarli nella pagina. Questi sono visibili nella barra laterale della pagina.',
     'attachments_explain_instant_save' => 'I cambiamenti qui sono salvati istantaneamente.',
-    'attachments_upload' => 'Carica File',
-    'attachments_link' => 'Allega Link',
+    'attachments_upload' => 'Carica file',
+    'attachments_link' => 'Allega collegamento',
     'attachments_upload_drop' => 'In alternativa puoi trascinare un file qui per caricarlo come allegato.',
-    'attachments_set_link' => 'Imposta Link',
-    'attachments_delete' => 'Sei sicuro di voler eliminare questo allegato?',
+    'attachments_set_link' => 'Imposta collegamento',
+    'attachments_delete' => 'Vuoi davvero eliminare questo allegato?',
     'attachments_dropzone' => 'Trascina qui i file da caricare',
     'attachments_no_files' => 'Nessun file è stato caricato',
-    'attachments_explain_link' => 'Puoi allegare un link se preferisci non caricare un file. Questo può essere un link a un\'altra pagina o a un file nel cloud.',
-    'attachments_link_name' => 'Nome Link',
-    'attachment_link' => 'Link allegato',
-    'attachments_link_url' => 'Link al file',
+    'attachments_explain_link' => 'Puoi allegare un collegamento se preferisci non caricare un file. Questo può essere un collegamento a un\'altra pagina o a un file nel cloud.',
+    'attachments_link_name' => 'Nome collegamento',
+    'attachment_link' => 'Collegamento allegato',
+    'attachments_link_url' => 'Collegamento al file',
     'attachments_link_url_hint' => 'Url del sito o del file',
     'attach' => 'Allega',
-    'attachments_insert_link' => 'Aggiungi link allegato alla pagina',
+    'attachments_insert_link' => 'Aggiungi allegato collegamento alla pagina',
     'attachments_edit_file' => 'Modifica file',
     'attachments_edit_file_name' => 'Nome file',
     'attachments_edit_drop_upload' => 'Trascina file qui o clicca per caricare e sovrascrivere',
@@ -349,7 +349,7 @@ return [
     'attachments_deleted' => 'Allegato eliminato',
     'attachments_file_uploaded' => 'File caricato correttamente',
     'attachments_file_updated' => 'File aggiornato correttamente',
-    'attachments_link_attached' => 'Link allegato correttamente alla pagina',
+    'attachments_link_attached' => 'Collegamento allegato correttamente alla pagina',
     'templates' => 'Modello',
     'templates_set_as_template' => 'La pagina è un modello',
     'templates_explain_set_as_template' => 'Puoi impostare questa pagina come modello in modo da utilizzare il suo contenuto quando si creano altre pagine. Gli altri utenti potranno utilizzare questo modello se avranno i permessi di visualizzazione per questa pagina.',
@@ -359,7 +359,7 @@ return [
 
     // Profile View
     'profile_user_for_x' => 'Utente da :time',
-    'profile_created_content' => 'Contenuti Creati',
+    'profile_created_content' => 'Contenuti creati',
     'profile_not_created_pages' => ':userName non ha creato pagine',
     'profile_not_created_chapters' => ':userName non ha creato capitoli',
     'profile_not_created_books' => ':userName non ha creato libri',
@@ -368,18 +368,18 @@ return [
     // Comments
     'comment' => 'Commento',
     'comments' => 'Commenti',
-    'comment_add' => 'Aggiungi Commento',
+    'comment_add' => 'Aggiungi commento',
     'comment_placeholder' => 'Scrivi un commento',
-    'comment_count' => '{0} Nessun Commento|{1} 1 Commento|[2,*] :count Commenti',
-    'comment_save' => 'Salva Commento',
-    'comment_new' => 'Nuovo Commento',
+    'comment_count' => '{0} Nessun commento|{1} 1 commento|[2,*] :count commenti',
+    'comment_save' => 'Salva commento',
+    'comment_new' => 'Nuovo commento',
     'comment_created' => 'ha commentato :createDiff',
     'comment_updated' => 'Aggiornato :updateDiff da :username',
     'comment_updated_indicator' => 'Aggiornato',
     'comment_deleted_success' => 'Commento eliminato',
     'comment_created_success' => 'Commento aggiunto',
     'comment_updated_success' => 'Commento aggiornato',
-    'comment_delete_confirm' => 'Sei sicuro di voler elminare questo commento?',
+    'comment_delete_confirm' => 'Sei sicuro di voler eliminare questo commento?',
     'comment_in_reply_to' => 'In risposta a :commentId',
     'comment_editor_explain' => 'Ecco i commenti che sono stati lasciati in questa pagina. I commenti possono essere aggiunti e gestiti quando si visualizza la pagina salvata.',
 
@@ -394,18 +394,18 @@ return [
     'copy_consider_owner' => 'Diventerai il proprietario di tutti i contenuti copiati.',
     'copy_consider_images' => 'I file delle immagini delle pagine non saranno duplicati e le immagini originali manterranno la loro relazione con la pagina su cui sono state originariamente caricate.',
     'copy_consider_attachments' => 'Gli allegati della pagina non saranno copiati.',
-    'copy_consider_access' => 'Un cambiamento di posizione, di proprietario o di autorizzazioni può far sì che questo contenuto sia accessibile a chi prima non aveva accesso.',
+    'copy_consider_access' => 'Un cambiamento di posizione, di proprietario o di autorizzazioni potrebbe rendere questo contenuto accessibile a chi prima non aveva accesso.',
 
     // Conversions
-    'convert_to_shelf' => 'Converti in Libreria',
+    'convert_to_shelf' => 'Converti in libreria',
     'convert_to_shelf_contents_desc' => 'Puoi convertire questo libro in una nuova libreria con gli stessi contenuti. I capitoli contenuti in questo libro saranno convertiti in nuovi libri. Se il libro contiene pagine che non fanno parte di un capitolo, questo libro verrà rinominato e conterrà tali pagine e diventerà parte della nuova libreria.',
     'convert_to_shelf_permissions_desc' => 'Tutti i permessi impostati su questo libro saranno copiati sulla nuova libreria e su tutti i nuovi libri figli che non hanno i loro permessi applicati. Nota che i permessi delle librerie non si trasmettono automaticamente ai contenuti al loro interno, come avviene per i libri.',
-    'convert_book' => 'Converti Libro',
+    'convert_book' => 'Converti libro',
     'convert_book_confirm' => 'Sei sicuro di voler convertire questo libro?',
     'convert_undo_warning' => 'Questo non può essere annullato con la stessa facilità.',
     'convert_to_book' => 'Converti in libro',
     'convert_to_book_desc' => 'È possibile convertire questo capitolo in un nuovo libro con gli stessi contenuti. Tutti i permessi impostati su questo capitolo saranno copiati nel nuovo libro, ma i permessi ereditati dal libro principale non saranno copiati, il che potrebbe portare a una modifica del controllo degli accessi.',
-    'convert_chapter' => 'Converti Capitolo',
+    'convert_chapter' => 'Converti capitolo',
     'convert_chapter_confirm' => 'Sei sicuro di voler convertire questo capitolo?',
 
     // References
@@ -415,25 +415,25 @@ return [
 
     // Watch Options
     'watch' => 'Osserva',
-    'watch_title_default' => 'Preferenze Predefinite',
-    'watch_desc_default' => 'Ripristina la visualizzazione delle tue preferenze di notifica predefinite.',
+    'watch_title_default' => 'Preferenze predefinite',
+    'watch_desc_default' => 'Ripristina l\'osservazione alle tue preferenze di notifica predefinite.',
     'watch_title_ignore' => 'Ignora',
-    'watch_desc_ignore' => 'Ignora tutte le notifiche, comprese quelle dalle preferenze di livello utente.',
-    'watch_title_new' => 'Nuove Pagine',
+    'watch_desc_ignore' => 'Ignora tutte le notifiche, incluse quelle dalle preferenze a livello utente.',
+    'watch_title_new' => 'Nuove pagine',
     'watch_desc_new' => 'Notifica quando viene creata una nuova pagina all\'interno di questo elemento.',
-    'watch_title_updates' => 'Tutti Gli Aggiornamenti Della Pagina',
-    'watch_desc_updates' => 'Notificare su tutte le nuove pagine e modifiche di pagina.',
-    'watch_desc_updates_page' => 'Notifica su tutte le modifiche alla pagina.',
-    'watch_title_comments' => 'Tutti Gli Aggiornamenti Della Pagina E Commenti',
-    'watch_desc_comments' => 'Notificare su tutte le nuove pagine, modifiche di pagina e nuovi commenti.',
-    'watch_desc_comments_page' => 'Notificare le modifiche alla pagina e i nuovi commenti.',
+    'watch_title_updates' => 'Tutti gli aggiornamenti della pagina',
+    'watch_desc_updates' => 'Notifica tutte le nuove pagine e le modifiche alle pagine.',
+    'watch_desc_updates_page' => 'Notifica tutte le modifiche alla pagine.',
+    'watch_title_comments' => 'Tutti gli aggiornamenti delle pagine e i commenti',
+    'watch_desc_comments' => 'Notifica tutte le nuove pagine, le modifiche alle pagine e i nuovi commenti.',
+    'watch_desc_comments_page' => 'Notifica le modifiche alla pagina e i nuovi commenti.',
     'watch_change_default' => 'Modifica le preferenze di notifica predefinite',
     'watch_detail_ignore' => 'Ignorare le notifiche',
-    'watch_detail_new' => 'In attesa di nuove pagine',
+    'watch_detail_new' => 'Osservare le nuove pagine',
     'watch_detail_updates' => 'Osservare le nuove pagine e gli aggiornamenti',
     'watch_detail_comments' => 'Osservare le nuove pagine, aggiornamenti e commenti',
-    'watch_detail_parent_book' => 'Osservare tramite il libro madre',
-    'watch_detail_parent_book_ignore' => 'Ignorato tramite il libro madre',
-    'watch_detail_parent_chapter' => 'Osservare tramite il capitolo madre',
-    'watch_detail_parent_chapter_ignore' => 'Ignorato tramite il capitolo madre',
+    'watch_detail_parent_book' => 'Osservare tramite il libro che lo contiene',
+    'watch_detail_parent_book_ignore' => 'Ignorare tramite il libro che lo contiene',
+    'watch_detail_parent_chapter' => 'Osservare tramite il capitolo che lo contiene',
+    'watch_detail_parent_chapter_ignore' => 'Ignorato tramite il capitolo che lo contiene',
 ];
index fe8c4c17c33204fd2d3e4eb6342521077e6f6e66..5120a6d71e91adcb1df1e839ff4133f56f7cbd56 100644 (file)
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Non puoi eliminare l\'unico admin',
     'users_cannot_delete_guest' => 'Non puoi eliminare l\'utente ospite',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Impossibile creare l\'utente poiché l\'invio dell\'email di invito non è riuscito',
 
     // Roles
     'role_cannot_be_edited' => 'Questo ruolo non può essere modificato',
index 097974d8e96ccbeed79a7067f1f42b1df208adde..4f15bafbb9dc659f6b55fe4c4bd5c206dce7aa35 100644 (file)
@@ -109,5 +109,5 @@ return [
     'terms_of_service' => '利用規約',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => ':appName を検索',
 ];
index eda30371bac10467fbf8f8cec7668b90fe0ce60f..489032835ccdc2ea450d8300fc329f4aa78f5289 100644 (file)
@@ -224,8 +224,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(クリーンなコンテンツ)',
     'pages_edit_switch_to_markdown_stable' => '(安定したコンテンツ)',
     'pages_edit_switch_to_wysiwyg' => 'WYSIWYGエディタに切り替え',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    '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' => '編集内容を入力',
index e1d7709651c8be6ad9e065c31e6bf2d0ddcfe383..4eba63659346dcea8c72bf2661d8082fbe169ca8 100644 (file)
@@ -78,7 +78,7 @@ return [
     // 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' => 'この役割は編集できません',
index 092398ef0e1475089fa71b7e60aa1f49fc056013..6c7af4429f0b8e94fca7aaa5f8f282cb79bc661a 100644 (file)
@@ -69,7 +69,7 @@ return [
     'mfa_setup_method' => 'configured MFA method',
     'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
     'mfa_remove_method' => 'removed MFA method',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_remove_method_notification' => 'მულტი-ფაქტორული მეთოდი წარმატებით მოიხსნა',
 
     // Settings
     'settings_update' => 'updated settings',
index 7bdcd3c610f5a6bc6bd2f7796e3ef4b308c9ecf9..bcf347956229b96ab558a1e365f3f3664987082e 100644 (file)
@@ -6,26 +6,26 @@
 return [
 
     // Pages
-    'page_create'                 => '생성된 페이지',
+    'page_create'                 => '페이지 생성',
     'page_create_notification'    => '페이지가 성공적으로 생성되었습니다.',
-    'page_update'                 => '페이지 업데이트',
+    'page_update'                 => '페이지 업데이트',
     'page_update_notification'    => '페이지가 성공적으로 업데이트되었습니다.',
-    'page_delete'                 => '삭제된 페이지',
+    'page_delete'                 => '페이지 삭제',
     'page_delete_notification'    => '페이지가 성공적으로 삭제되었습니다.',
-    'page_restore'                => '복구된 페이지',
+    'page_restore'                => '페이지 복원',
     'page_restore_notification'   => '페이지가 성공적으로 복원되었습니다.',
-    'page_move'                   => '이동된 페이지',
+    'page_move'                   => '페이지 이동',
     'page_move_notification'      => '페이지가 성공적으로 이동되었습니다.',
 
     // Chapters
-    'chapter_create'              => '챕터 만들기',
+    'chapter_create'              => '챕터 생성',
     'chapter_create_notification' => '챕터가 성공적으로 생성되었습니다.',
-    'chapter_update'              => 'ì\97\85ë\8d°ì\9d´í\8a¸ë\90\9c ì±\95í\84°',
-    'chapter_update_notification' => 'ì±\95í\84°ê°\80 ì\84±ê³µì \81ì\9c¼ë¡\9c ì\97\85ë\8d°ì\9d´í\8a¸되었습니다.',
-    'chapter_delete'              => 'ì\82­ì \9cë\90\9c ì±\95í\84°',
+    'chapter_update'              => 'ì±\95í\84° ì\88\98ì \95',
+    'chapter_update_notification' => 'ì±\95í\84°ê°\80 ì\84±ê³µì \81ì\9c¼ë¡\9c ì\88\98ì \95되었습니다.',
+    'chapter_delete'              => 'ì±\95í\84° ì\82­ì \9c',
     'chapter_delete_notification' => '챕터가 성공적으로 삭제되었습니다.',
-    'chapter_move'                => 'ì\9d´ë\8f\99ë\90\9c ì±\95í\84°',
-    'chapter_move_notification' => 'ì±\95í\84°ë¥¼ ì\84±ê³µì \81ì\9c¼ë¡\9c ì\9d´ë\8f\99í\96\88ì\8aµë\8b\88ë\8b¤.',
+    'chapter_move'                => 'ì±\95í\84° ì\9d´ë\8f\99',
+    'chapter_move_notification' => 'í\8e\98ì\9d´ì§\80ê°\80 ì\84±ê³µì \81ì\9c¼ë¡\9c ì\9d´ë\8f\99ë\90\98ì\97\88ì\8aµë\8b\88ë\8b¤.',
 
     // Books
     'book_create'                 => '생성된 책',
@@ -36,7 +36,7 @@ return [
     'book_update_notification'    => '책이 성공적으로 업데이트되었습니다.',
     'book_delete'                 => '삭제된 책',
     'book_delete_notification'    => '책이 성공적으로 삭제되었습니다.',
-    'book_sort'                   => 'ì \95ë ¬ë\90\9c ì±\85',
+    'book_sort'                   => 'ì±\85 ì \95ë ¬',
     'book_sort_notification'      => '책이 성공적으로 재정렬되었습니다.',
 
     // Bookshelves
@@ -51,8 +51,8 @@ return [
 
     // Revisions
     'revision_restore' => '복구된 리비전',
-    'revision_delete' => '삭제된 리비전',
-    'revision_delete_notification' => '리ë¹\84ì \84ì\9d\84 ì\84±ê³µì \81ì\9c¼ë¡\9c ì\82­ì \9cí\95\98ì\98\80ì\8aµë\8b\88ë\8b¤.',
+    'revision_delete' => '버전 삭제',
+    'revision_delete_notification' => 'ë²\84ì \84 ì\82­ì \9c ì\84±ê³µ',
 
     // Favourites
     'favourite_add_notification' => '":name" 을 북마크에 추가하였습니다.',
@@ -62,14 +62,14 @@ return [
     'watch_update_level_notification' => '주시 환경설정이 성공적으로 업데이트되었습니다.',
 
     // Auth
-    'auth_login' => '로그인 ',
+    'auth_login' => '로그인 완료',
     'auth_register' => '신규 사용자 등록',
     'auth_password_reset_request' => '사용자 비밀번호 초기화 요청',
     'auth_password_reset_update' => '사용자 비밀번호 초기화',
-    'mfa_setup_method' => '구성된 MFA 방법',
+    'mfa_setup_method' => '다중인증(MFA)이 구성되었습니다.',
     'mfa_setup_method_notification' => '다중 인증 설정함',
     'mfa_remove_method' => 'MFA 메서드 제거',
-    'mfa_remove_method_notification' => '다중 인증 해제함',
+    'mfa_remove_method_notification' => '다중인증(MFA)이 성공적으로 제거되었습니다.',
 
     // Settings
     'settings_update' => '설정 변경',
@@ -80,7 +80,7 @@ return [
     'webhook_create' => '웹 훅 만들기',
     'webhook_create_notification' => '웹 훅 생성함',
     'webhook_update' => '웹 훅 수정하기',
-    'webhook_update_notification' => '웹 훅 수정함',
+    'webhook_update_notification' => '웹훅 설정이 수정되었습니다.',
     'webhook_delete' => '웹 훅 지우기',
     'webhook_delete_notification' => '웹 훅 삭제함',
 
index 844fe91d1aff7a21c360ac29e1ecdacbed269f63..843b02b0fb77308871baeea2931bd0e0f67e55e3 100644 (file)
@@ -109,5 +109,5 @@ return [
     'terms_of_service' => 'Algemene voorwaarden',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Zoek in :appName',
 ];
index 9daec1c1fc4fd364a20abe6f33aedd18fd028637..36955c8e1f5fafc5f93ee899a86324fc082ff05e 100644 (file)
@@ -18,8 +18,8 @@ return [
     'create_now' => 'Maak er nu één',
     'revisions' => 'Revisies',
     'meta_revision' => 'Revisie #:revisionCount',
-    'meta_created' => 'Gemaakt op: :timeLength',
-    'meta_created_name' => 'Gemaakt op :timeLength door :user',
+    'meta_created' => 'Gemaakt: :timeLength',
+    'meta_created_name' => 'Gemaakt: :timeLength door :user',
     'meta_updated' => 'Bijgewerkt: :timeLength',
     'meta_updated_name' => 'Bijgewerkt: :timeLength door :user',
     'meta_owned_name' => 'Eigendom van :user',
index a6ec53883ea880d1e8f01e27aad594c4d53d9fb3..0c83cb7f31fab745aae392bce74249200546e363 100644 (file)
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Je kunt niet het enige admin account verwijderen',
     'users_cannot_delete_guest' => 'Je kunt het gastaccount niet verwijderen',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Kan de gebruiker niet aanmaken, uitnodigingsmail kon niet worden verzonden',
 
     // Roles
     'role_cannot_be_edited' => 'Deze rol kan niet bewerkt worden',
index 3f997c404bc5b04b5b2d8dde51c5342c01bae98b..1d0b14b5cf56a0ef2adaac3af809d95614f0450a 100644 (file)
@@ -208,7 +208,7 @@ return [
     'users_avatar' => 'Avatar',
     'users_avatar_desc' => 'Selecteer een afbeelding om deze gebruiker voor te stellen. Deze moet ongeveer 256px breed en vierkant zijn.',
     'users_preferred_language' => 'Voorkeurstaal',
-    'users_preferred_language_desc' => 'Deze optie wijzigt de taal die gebruikt wordt voor de gebruikersinterface. Dit heeft geen invloed op door gebruiker gemaakte inhoud.',
+    'users_preferred_language_desc' => 'Deze optie wijzigt de taal die gebruikt wordt voor de gebruikersinterface. Dit heeft geen invloed op door gebruikers gemaakte inhoud.',
     'users_social_accounts' => 'Sociale media accounts',
     'users_social_accounts_desc' => 'Bekijk de status van de verbonden socialmedia-accounts voor deze gebruiker. socialmedia-accounts kunnen worden gebruikt naast het primaire authenticatiesysteem voor systeemtoegang.',
     'users_social_accounts_info' => 'Hier kun je je andere accounts koppelen om sneller en eenvoudiger in te loggen. Als je hier een account loskoppelt, wordt de eerder gemachtigde toegang niet ingetrokken. Je kunt de toegang intrekken via je profielinstellingen op het gekoppelde socialemedia-account zelf.',
index 36b7d847b4465c6a5afd132fe4bd7b002863896c..73182e0ba843ea8b2a7b9b2110d0be0c41124a89 100644 (file)
@@ -93,11 +93,11 @@ return [
     'user_delete_notification' => 'Brukaren vart fjerna',
 
     // API Tokens
-    'api_token_create' => 'created API token',
+    'api_token_create' => 'opprett API-nøkkel',
     'api_token_create_notification' => 'API-token er oppretta',
-    'api_token_update' => 'updated API token',
+    'api_token_update' => 'oppdatert api token',
     'api_token_update_notification' => 'API-token oppdatert',
-    'api_token_delete' => 'deleted API token',
+    'api_token_delete' => 'sletta api token',
     'api_token_delete_notification' => 'API-token vart sletta',
 
     // Roles
index 04546da1dc46ce185451cbf3c9f5579f7c3e4c59..5da686eaa9e3d6737d445180de919f912bb57d90 100644 (file)
@@ -91,7 +91,7 @@ return [
     'mfa_option_totp_title' => 'Mobilapplikasjon',
     'mfa_option_totp_desc' => 'For å bruka fleirfaktorautentisering treng du ein mobilapplikasjon som støttar TOTP-teknologien, slik som Google Authenticator, Authy eller Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'Tryggleikskodar',
-    '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' => 'Genererer et sett med engangs sikkerhetskoder som du skal logge inn for å bekrefte identiteten din. Sørg for å lagre disse på et sikkert og sikkert sted.',
     'mfa_gen_confirm_and_enable' => 'Stadfest og aktiver',
     'mfa_gen_backup_codes_title' => 'Konfigurasjon av tryggleikskodar',
     'mfa_gen_backup_codes_desc' => 'Lagre lista under med kodar på ein trygg stad. Når du skal ha tilgang til systemet kan du bruka ein av desse som ein faktor under innlogging.',
index e56e8ab43f198ad72feaf1c613ed74fde321149a..445f8198af89c9656a78e75e32ebf5a099b10b2d 100644 (file)
@@ -91,7 +91,7 @@ return [
     'mfa_option_totp_title' => 'Aplikacja mobilna',
     'mfa_option_totp_desc' => 'Aby korzystać z uwierzytelniania wieloskładnikowego, potrzebujesz aplikacji mobilnej, która obsługuje TOTP, takiej jak Google Authenticator, Authy lub Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'Kody zapasowe',
-    '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' => 'Generuje zestaw jednorazowych kodów zapasowych, które wprowadzisz przy logowaniu, aby zweryfikować Twoją tożsamość. Upewnij się, że przechowujesz je w bezpiecznym miejscu.',
     'mfa_gen_confirm_and_enable' => 'Potwierdź i włącz',
     'mfa_gen_backup_codes_title' => 'Ustawienia kopii zapasowych kodów',
     'mfa_gen_backup_codes_desc' => 'Przechowuj poniższą listę kodów w bezpiecznym miejscu. Przy dostępie do systemu będziesz mógł użyć jednego z kodów jako drugiego mechanizmu uwierzytelniania.',
index 4d5a346b2b5b160dd05df1a346f9cf34729b8019..a690cdb041ee532f6738710bd778e1ce01933117 100644 (file)
@@ -109,5 +109,5 @@ return [
     'terms_of_service' => 'Warunki usługi',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Szukaj :appName',
 ];
index 0941a1fb9921aa872b473516ee8c2db4ed9deb92..f0ff5a251f5e4cb61f21a4c2a6b860c755ad047f 100644 (file)
@@ -224,8 +224,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(Czysta zawartość)',
     'pages_edit_switch_to_markdown_stable' => '(Statyczna zawartość)',
     'pages_edit_switch_to_wysiwyg' => 'Przełącz na edytor WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'Przełącz na nowy WYSIWYG',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(Alfa testy)',
     'pages_edit_set_changelog' => 'Ustaw dziennik zmian',
     'pages_edit_enter_changelog_desc' => 'Opisz zmiany, które zostały wprowadzone',
     'pages_edit_enter_changelog' => 'Wyświetl dziennik zmian',
index 5fb78a9833431709761520cbfff123e537d989f1..71f592ad6835c3f1ec1cd9f833cec1968a2c3ad9 100644 (file)
@@ -37,7 +37,7 @@ return [
     'social_driver_not_found' => 'Funkcja społecznościowa nie została odnaleziona',
     'social_driver_not_configured' => 'Ustawienia konta :socialAccount nie są poprawne.',
     'invite_token_expired' => 'Zaproszenie wygasło. Możesz spróować zresetować swoje hasło.',
-    'login_user_not_found' => 'A user for this action could not be found.',
+    'login_user_not_found' => 'Użytkownik dla tej akcji nie został znaleziony.',
 
     // System
     'path_not_writable' => 'Zapis do ścieżki :filePath jest niemożliwy. Upewnij się że aplikacja ma prawa do zapisu plików na serwerze.',
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Nie możesz usunąć jedynego administratora',
     'users_cannot_delete_guest' => 'Nie możesz usunąć użytkownika-gościa',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Nie można utworzyć użytkownika, ponieważ nie udało się wysłać wiadomości e-mail z zaproszeniem',
 
     // Roles
     'role_cannot_be_edited' => 'Ta rola nie może być edytowana',
index 80ca8e9a3101e61e6e987514f97ed4537fa555bf..42e20af87dfbad4ddb970314586958fcf98efb0c 100644 (file)
@@ -31,7 +31,7 @@ return [
     'header_large' => 'Cabeçalho Grande',
     'header_medium' => 'Cabeçalho Médio',
     'header_small' => 'Cabeçalho Pequeno',
-    'header_tiny' => 'Cabeçalho Pequeno',
+    'header_tiny' => 'Cabeçalho Minúsculo',
     'paragraph' => 'Parágrafo',
     'blockquote' => 'Bloco de Citação',
     'inline_code' => 'Código embutido',
index 41d4516b9004b5e90f63b2a142a9a583f8aec3b2..d23f677d9f5c940a71ac6570ac6884250d1a8ce3 100644 (file)
@@ -6,88 +6,88 @@
 return [
 
     // Shared
-    'recently_created' => 'Criados Recentemente',
-    'recently_created_pages' => 'Páginas Criadas Recentemente',
-    'recently_updated_pages' => 'Páginas Atualizadas Recentemente',
-    'recently_created_chapters' => 'Capítulos Criados Recentemente',
-    'recently_created_books' => 'Livros Criados Recentemente',
+    'recently_created' => 'Criado Recentemente',
+    'recently_created_pages' => 'Páginas Recentemente Criadas',
+    'recently_updated_pages' => 'Páginas Recentemente Atualizadas',
+    'recently_created_chapters' => 'Capítulos Recentemente Criados',
+    'recently_created_books' => 'Livros Recentemente Criados',
     'recently_created_shelves' => 'Prateleiras Criadas Recentemente',
-    'recently_update' => 'Atualizados Recentemente',
-    'recently_viewed' => 'Visualizados Recentemente',
-    'recent_activity' => 'Atividade Recente',
-    'create_now' => 'Criar um agora',
+    'recently_update' => 'Recentemente Atualizado',
+    'recently_viewed' => 'Recentemente Visualizado',
+    'recent_activity' => 'Atividades recentes',
+    'create_now' => 'Criar agora',
     'revisions' => 'Revisões',
-    'meta_revision' => 'Revisão #:revisionCount',
-    'meta_created' => 'Criado :timeLength',
-    'meta_created_name' => 'Criado :timeLength por :user',
-    'meta_updated' => 'Atualizado :timeLength',
-    'meta_updated_name' => 'Atualizado :timeLength por :user',
-    'meta_owned_name' => 'De :user',
-    'meta_reference_count' => 'Referenciado no item :count|Referenciado nos items :count',
-    'entity_select' => 'Seleção de Entidade',
-    'entity_select_lack_permission' => 'Você não tem as permissões necessárias para selecionar este item',
+    'meta_revision' => 'Revisão #: contagem de revisões',
+    'meta_created' => 'Criado: duração de tempo',
+    'meta_created_name' => 'Criado: duração de tempo por usuário',
+    'meta_updated' => 'Atualizado: duração de tempo',
+    'meta_updated_name' => 'Atualizado: duração de tempo por usuário',
+    'meta_owned_name' => 'Propriedade de: usuário',
+    'meta_reference_count' => 'Referenciado por: count item|Referenciado por: count itens',
+    'entity_select' => 'Seleção de entidade',
+    'entity_select_lack_permission' => 'Você não tem as permissões necessárias para selecionar este ‘item’',
     'images' => 'Imagens',
     'my_recent_drafts' => 'Meus Rascunhos Recentes',
-    'my_recently_viewed' => 'Visualizados por mim Recentemente',
+    'my_recently_viewed' => 'Visualizados Recentemente',
     'my_most_viewed_favourites' => 'Meus Favoritos Mais Visualizados',
-    'my_favourites' => 'Meus Favoritos',
+    'my_favourites' => 'Favoritos',
     'no_pages_viewed' => 'Você não visualizou nenhuma página',
-    'no_pages_recently_created' => 'Nenhuma página criada recentemente',
-    'no_pages_recently_updated' => 'Nenhuma página atualizada recentemente',
+    'no_pages_recently_created' => 'Nenhuma página foi criada recentemente',
+    'no_pages_recently_updated' => 'Nenhuma página foi atualizada recentemente',
     'export' => 'Exportar',
-    'export_html' => 'Arquivo Web Contained',
+    'export_html' => 'Arquivo Web Contido',
     'export_pdf' => 'Arquivo PDF',
-    'export_text' => 'Arquivo Texto',
-    'export_md' => 'Arquivos para remarcar',
-    'default_template' => 'Modelo de página padrão',
-    'default_template_explain' => 'Atribuir o modelo de página que sera usado como padrão para todas as páginas criadas neste livro. Tenha em mente que isto será usado apenas se o criador da página tiver acesso de visualização ao modelo de página escolhido.',
-    'default_template_select' => 'Selecione o modelo de página',
+    'export_text' => 'Arquivo de texto simples',
+    'export_md' => 'Arquivo de redução',
+    'default_template' => 'Modelo padrão de página',
+    'default_template_explain' => 'Atribuir o modelo de página que será usado como padrão para todas as páginas criadas neste livro. Tenha em mente que isto será usado apenas se o criador da página tiver acesso de visualização ao modelo de página escolhido.',
+    'default_template_select' => 'Selecione uma página de modelo',
 
     // Permissions and restrictions
     'permissions' => 'Permissões',
-    'permissions_desc' => 'Defina as permissões aqui para substituir as permissões padrão fornecidas pelas funções do usuário.',
-    'permissions_book_cascade' => 'As permissões definidas em livros serão automaticamente em cascata para capítulos e páginas filho, a menos que tenham suas próprias permissões definidas.',
-    'permissions_chapter_cascade' => 'As permissões definidas nos capítulos serão automaticamente em cascata para as páginas filhas, a menos que tenham suas próprias permissões definidas.',
-    'permissions_save' => 'Salvar Permissões',
-    'permissions_owner' => 'Proprietário',
-    'permissions_role_everyone_else' => 'Todos os outros',
-    'permissions_role_everyone_else_desc' => 'Defina permissões para todas as funções não especificamente substituídas.',
-    'permissions_role_override' => 'Substituir permissões para função',
-    'permissions_inherit_defaults' => 'Herdar padrões',
+    'permissions_desc' => 'Defina permissões aqui para substituir as permissões padrão fornecidas pelas funções do usuário.',
+    'permissions_book_cascade' => 'As permissões definidas nos livros serão automaticamente transmitidas aos capítulos e páginas secundários, a menos que eles tenham suas próprias permissões definidas.',
+    'permissions_chapter_cascade' => 'As permissões definidas nos capítulos serão automaticamente transmitidas às páginas secundárias, a menos que elas tenham suas próprias permissões definidas.',
+    'permissions_save' => 'Salvar permissões',
+    'permissions_owner' => 'Dono',
+    'permissions_role_everyone_else' => 'Todos os Outros',
+    'permissions_role_everyone_else_desc' => 'Defina permissões para todas as funções não substituídas especificamente.',
+    'permissions_role_override' => 'Substituir permissões para o papel',
+    'permissions_inherit_defaults' => 'Herdar Padrões',
 
     // Search
     'search_results' => 'Resultado(s) da Pesquisa',
-    'search_total_results_found' => ':count resultado encontrado|:count resultados encontrados',
+    'search_total_results_found' => ':count resultado encontrado (contagem de resultados) :count resultados totais encontrados',
     'search_clear' => 'Limpar Pesquisa',
-    'search_no_pages' => 'Nenhuma página corresponde à pesquisa',
-    'search_for_term' => 'Pesquisar por :term',
-    'search_more' => 'Mais Resultados',
-    'search_advanced' => 'Pesquisa Avançada',
-    'search_terms' => 'Termos da Pesquisa',
-    'search_content_type' => 'Tipo de Conteúdo',
-    'search_exact_matches' => 'Correspondências Exatas',
-    'search_tags' => 'Persquisar Tags',
+    'search_no_pages' => 'Nenhuma página corresponde a esta pesquisa',
+    'search_for_term' => 'Pesquisar por: termo',
+    'search_more' => 'Mais resultados',
+    'search_advanced' => 'Pesquisa avançada',
+    'search_terms' => 'Termos da pesquisa',
+    'search_content_type' => 'Categoria de conteúdo',
+    'search_exact_matches' => 'Correspondências exatas',
+    'search_tags' => 'Etiqueta de buscas',
     'search_options' => 'Opções',
-    'search_viewed_by_me' => 'Visualizado por mim',
-    'search_not_viewed_by_me' => 'Não visualizado por mim',
-    'search_permissions_set' => 'Permissão definida',
-    'search_created_by_me' => 'Criado por mim',
-    'search_updated_by_me' => 'Atualizado por mim',
-    'search_owned_by_me' => 'Possuído por mim',
+    'search_viewed_by_me' => 'Visto por mim',
+    'search_not_viewed_by_me' => 'Não visto por mim',
+    'search_permissions_set' => 'Permissões definidas',
+    'search_created_by_me' => 'Criados por mim',
+    'search_updated_by_me' => 'Atualizados por mim',
+    'search_owned_by_me' => 'Meus itens',
     'search_date_options' => 'Opções de Data',
-    'search_updated_before' => 'Atualizado antes de',
-    'search_updated_after' => 'Atualizado depois de',
-    'search_created_before' => 'Criado antes de',
-    'search_created_after' => 'Criado depois de',
+    'search_updated_before' => 'Atualizado antes',
+    'search_updated_after' => 'Atualizado depois',
+    'search_created_before' => 'Criado antes',
+    'search_created_after' => 'Criado depois',
     'search_set_date' => 'Definir Data',
-    'search_update' => 'Refazer Pesquisa',
+    'search_update' => 'Atualizar pesquisa',
 
     // Shelves
     'shelf' => 'Estante',
     'shelves' => 'Estantes',
-    'x_shelves' => ':count Estante|:count Estantes',
+    'x_shelves' => ': count Estante|: count Estantes',
     'shelves_empty' => 'Nenhuma estante foi criada',
-    'shelves_create' => 'Criar Nova Estante',
+    'shelves_create' => 'Criar Prateleira',
     'shelves_popular' => 'Estantes Populares',
     'shelves_new' => 'Novas Estantes',
     'shelves_new_action' => 'Nova Estante',
@@ -111,7 +111,7 @@ return [
     'shelves_permissions_cascade_warning' => 'As permissões nas prateleiras não são automaticamente em cascata para os livros contidos. Isso ocorre porque um livro pode existir em várias prateleiras. No entanto, as permissões podem ser copiadas para livros filhos usando a opção encontrada abaixo.',
     'shelves_permissions_create' => 'As permissões de criação de prateleira são usadas apenas para copiar livros filhos usando a ação abaixo. Eles não controlam a capacidade de criar livros.',
     'shelves_copy_permissions_to_books' => 'Copiar Permissões para Livros',
-    'shelves_copy_permissions' => 'Copiar Permissões',
+    'shelves_copy_permissions' => 'Copiar permissões',
     'shelves_copy_permissions_explain' => 'Isso aplicará as configurações de permissão atuais desta estante a todos os livros contidos nela. Antes de ativar, verifique se todas as alterações nas permissões desta prateleira foram salvas.',
     'shelves_copy_permission_success' => 'Permissões de prateleira copiadas para :count books',
 
@@ -224,8 +224,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(Conteúdo Limpo)',
     '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 Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'Mudar para o novo WYSIWYG',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(No Teste Alfa)',
     'pages_edit_set_changelog' => 'Relatar Alterações',
     'pages_edit_enter_changelog_desc' => 'Digite uma breve descrição das alterações efetuadas por você',
     'pages_edit_enter_changelog' => 'Insira Alterações',
diff --git a/lang/tk/activities.php b/lang/tk/activities.php
new file mode 100644 (file)
index 0000000..092398e
--- /dev/null
@@ -0,0 +1,124 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'created page',
+    'page_create_notification'    => 'Page successfully created',
+    'page_update'                 => 'updated page',
+    'page_update_notification'    => 'Page successfully updated',
+    'page_delete'                 => 'deleted page',
+    'page_delete_notification'    => 'Page successfully deleted',
+    'page_restore'                => 'restored page',
+    'page_restore_notification'   => 'Page successfully restored',
+    'page_move'                   => 'moved page',
+    'page_move_notification'      => 'Page successfully moved',
+
+    // Chapters
+    'chapter_create'              => 'created chapter',
+    'chapter_create_notification' => 'Chapter successfully created',
+    'chapter_update'              => 'updated chapter',
+    'chapter_update_notification' => 'Chapter successfully updated',
+    'chapter_delete'              => 'deleted chapter',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
+    'chapter_move'                => 'moved chapter',
+    'chapter_move_notification' => 'Chapter successfully moved',
+
+    // Books
+    'book_create'                 => 'created book',
+    'book_create_notification'    => 'Book successfully created',
+    'book_create_from_chapter'              => 'converted chapter to book',
+    'book_create_from_chapter_notification' => 'Chapter successfully converted to a book',
+    'book_update'                 => 'updated book',
+    'book_update_notification'    => 'Book successfully updated',
+    'book_delete'                 => 'deleted book',
+    'book_delete_notification'    => 'Book successfully deleted',
+    'book_sort'                   => 'sorted book',
+    'book_sort_notification'      => 'Book successfully re-sorted',
+
+    // Bookshelves
+    'bookshelf_create'            => 'created shelf',
+    'bookshelf_create_notification'    => 'Shelf successfully created',
+    'bookshelf_create_from_book'    => 'converted book to shelf',
+    'bookshelf_create_from_book_notification'    => 'Book successfully converted to a shelf',
+    'bookshelf_update'                 => 'updated shelf',
+    'bookshelf_update_notification'    => 'Shelf successfully updated',
+    'bookshelf_delete'                 => 'deleted shelf',
+    'bookshelf_delete_notification'    => 'Shelf successfully deleted',
+
+    // Revisions
+    'revision_restore' => 'restored revision',
+    'revision_delete' => 'deleted revision',
+    'revision_delete_notification' => 'Revision successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // Watching
+    'watch_update_level_notification' => 'Watch preferences successfully updated',
+
+    // Auth
+    'auth_login' => 'logged in',
+    'auth_register' => 'registered as new user',
+    'auth_password_reset_request' => 'requested user password reset',
+    'auth_password_reset_update' => 'reset user password',
+    'mfa_setup_method' => 'configured MFA method',
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method' => 'removed MFA method',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Settings
+    'settings_update' => 'updated settings',
+    'settings_update_notification' => 'Settings successfully updated',
+    'maintenance_action_run' => 'ran maintenance action',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
+
+    // Users
+    'user_create' => 'created user',
+    'user_create_notification' => 'User successfully created',
+    'user_update' => 'updated user',
+    'user_update_notification' => 'User successfully updated',
+    'user_delete' => 'deleted user',
+    'user_delete_notification' => 'User successfully removed',
+
+    // API Tokens
+    'api_token_create' => 'created API token',
+    'api_token_create_notification' => 'API token successfully created',
+    'api_token_update' => 'updated API token',
+    'api_token_update_notification' => 'API token successfully updated',
+    'api_token_delete' => 'deleted API token',
+    'api_token_delete_notification' => 'API token successfully deleted',
+
+    // Roles
+    'role_create' => 'created role',
+    'role_create_notification' => 'Role successfully created',
+    'role_update' => 'updated role',
+    'role_update_notification' => 'Role successfully updated',
+    'role_delete' => 'deleted role',
+    'role_delete_notification' => 'Role successfully deleted',
+
+    // Recycle Bin
+    'recycle_bin_empty' => 'emptied recycle bin',
+    'recycle_bin_restore' => 'restored from recycle bin',
+    'recycle_bin_destroy' => 'removed from recycle bin',
+
+    // Comments
+    'commented_on'                => 'commented on',
+    'comment_create'              => 'added comment',
+    'comment_update'              => 'updated comment',
+    'comment_delete'              => 'deleted comment',
+
+    // Other
+    'permissions_update'          => 'updated permissions',
+];
diff --git a/lang/tk/auth.php b/lang/tk/auth.php
new file mode 100644 (file)
index 0000000..57f0cb5
--- /dev/null
@@ -0,0 +1,117 @@
+<?php
+/**
+ * Authentication Language Lines
+ * The following language lines are used during authentication for various
+ * messages that we need to display to the user.
+ */
+return [
+
+    'failed' => 'These credentials do not match our records.',
+    'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
+
+    // Login & Register
+    'sign_up' => 'Sign up',
+    'log_in' => 'Log in',
+    'log_in_with' => 'Login with :socialDriver',
+    'sign_up_with' => 'Sign up with :socialDriver',
+    'logout' => 'Logout',
+
+    'name' => 'Name',
+    'username' => 'Username',
+    'email' => 'Email',
+    'password' => 'Password',
+    'password_confirm' => 'Confirm Password',
+    'password_hint' => 'Must be at least 8 characters',
+    'forgot_password' => 'Forgot Password?',
+    'remember_me' => 'Remember Me',
+    'ldap_email_hint' => 'Please enter an email to use for this account.',
+    'create_account' => 'Create Account',
+    'already_have_account' => 'Already have an account?',
+    'dont_have_account' => 'Don\'t have an account?',
+    'social_login' => 'Social Login',
+    'social_registration' => 'Social Registration',
+    'social_registration_text' => 'Register and sign in using another service.',
+
+    'register_thanks' => 'Thanks for registering!',
+    'register_confirm' => 'Please check your email and click the confirmation button to access :appName.',
+    'registrations_disabled' => 'Registrations are currently disabled',
+    'registration_email_domain_invalid' => 'That email domain does not have access to this application',
+    'register_success' => 'Thanks for signing up! You are now registered and signed in.',
+
+    // Login auto-initiation
+    'auto_init_starting' => 'Attempting Login',
+    'auto_init_starting_desc' => 'We\'re contacting your authentication system to start the login process. If there\'s no progress after 5 seconds you can try clicking the link below.',
+    'auto_init_start_link' => 'Proceed with authentication',
+
+    // Password Reset
+    'reset_password' => 'Reset Password',
+    'reset_password_send_instructions' => 'Enter your email below and you will be sent an email with a password reset link.',
+    'reset_password_send_button' => 'Send Reset Link',
+    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
+    'reset_password_success' => 'Your password has been successfully reset.',
+    'email_reset_subject' => 'Reset your :appName password',
+    'email_reset_text' => 'You are receiving this email because we received a password reset request for your account.',
+    'email_reset_not_requested' => 'If you did not request a password reset, no further action is required.',
+
+    // Email Confirmation
+    'email_confirm_subject' => 'Confirm your email on :appName',
+    'email_confirm_greeting' => 'Thanks for joining :appName!',
+    'email_confirm_text' => 'Please confirm your email address by clicking the button below:',
+    'email_confirm_action' => 'Confirm Email',
+    'email_confirm_send_error' => 'Email confirmation required but the system could not send the email. Contact the admin to ensure email is set up correctly.',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
+    'email_confirm_resent' => 'Confirmation email resent, Please check your inbox.',
+    'email_confirm_thanks' => 'Thanks for confirming!',
+    'email_confirm_thanks_desc' => 'Please wait a moment while your confirmation is handled. If you are not redirected after 3 seconds press the "Continue" link below to proceed.',
+
+    'email_not_confirmed' => 'Email Address Not Confirmed',
+    'email_not_confirmed_text' => 'Your email address has not yet been confirmed.',
+    'email_not_confirmed_click_link' => 'Please click the link in the email that was sent shortly after you registered.',
+    'email_not_confirmed_resend' => 'If you cannot find the email you can re-send the confirmation email by submitting the form below.',
+    'email_not_confirmed_resend_button' => 'Resend Confirmation Email',
+
+    // User Invite
+    'user_invite_email_subject' => 'You have been invited to join :appName!',
+    'user_invite_email_greeting' => 'An account has been created for you on :appName.',
+    'user_invite_email_text' => 'Click the button below to set an account password and gain access:',
+    'user_invite_email_action' => 'Set Account Password',
+    'user_invite_page_welcome' => 'Welcome to :appName!',
+    'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.',
+    'user_invite_page_confirm_button' => 'Confirm Password',
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => '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.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    '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_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
+    'mfa_verify_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
diff --git a/lang/tk/common.php b/lang/tk/common.php
new file mode 100644 (file)
index 0000000..b05169b
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'Cancel',
+    'close' => 'Close',
+    'confirm' => 'Confirm',
+    'back' => 'Back',
+    'save' => 'Save',
+    'continue' => 'Continue',
+    'select' => 'Select',
+    'toggle_all' => 'Toggle All',
+    'more' => 'More',
+
+    // Form Labels
+    'name' => 'Name',
+    'description' => 'Description',
+    'role' => 'Role',
+    'cover_image' => 'Cover image',
+    '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.',
+
+    // Actions
+    'actions' => 'Actions',
+    'view' => 'View',
+    'view_all' => 'View All',
+    'new' => 'New',
+    'create' => 'Create',
+    'update' => 'Update',
+    'edit' => 'Edit',
+    'sort' => 'Sort',
+    'move' => 'Move',
+    'copy' => 'Copy',
+    'reply' => 'Reply',
+    'delete' => 'Delete',
+    'delete_confirm' => 'Confirm Deletion',
+    'search' => 'Search',
+    'search_clear' => 'Clear Search',
+    'reset' => 'Reset',
+    'remove' => 'Remove',
+    'add' => 'Add',
+    'configure' => 'Configure',
+    'manage' => 'Manage',
+    'fullscreen' => 'Fullscreen',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
+    'download' => 'Download',
+    'open_in_tab' => 'Open in Tab',
+    'open' => 'Open',
+
+    // Sort Options
+    'sort_options' => 'Sort Options',
+    'sort_direction_toggle' => 'Sort Direction Toggle',
+    'sort_ascending' => 'Sort Ascending',
+    'sort_descending' => 'Sort Descending',
+    'sort_name' => 'Name',
+    'sort_default' => 'Default',
+    'sort_created_at' => 'Created Date',
+    'sort_updated_at' => 'Updated Date',
+
+    // Misc
+    'deleted_user' => 'Deleted User',
+    'no_activity' => 'No activity to show',
+    'no_items' => 'No items available',
+    'back_to_top' => 'Back to top',
+    'skip_to_main_content' => 'Skip to main content',
+    'toggle_details' => 'Toggle Details',
+    'toggle_thumbnails' => 'Toggle Thumbnails',
+    'details' => 'Details',
+    'grid_view' => 'Grid View',
+    'list_view' => 'List View',
+    'default' => 'Default',
+    'breadcrumb' => 'Breadcrumb',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
+
+    // Header
+    'homepage' => 'Homepage',
+    'header_menu_expand' => 'Expand Header Menu',
+    'profile_menu' => 'Profile Menu',
+    'view_profile' => 'View Profile',
+    'edit_profile' => 'Edit Profile',
+    'dark_mode' => 'Dark Mode',
+    'light_mode' => 'Light Mode',
+    'global_search' => 'Global Search',
+
+    // Layout tabs
+    'tab_info' => 'Info',
+    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_content' => 'Content',
+    'tab_content_label' => 'Tab: Show Primary Content',
+
+    // Email Content
+    'email_action_help' => 'If you’re having trouble clicking the ":actionText" button, copy and paste the URL below into your web browser:',
+    'email_rights' => 'All rights reserved',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privacy Policy',
+    'terms_of_service' => 'Terms of Service',
+
+    // OpenSearch
+    'opensearch_description' => 'Search :appName',
+];
diff --git a/lang/tk/components.php b/lang/tk/components.php
new file mode 100644 (file)
index 0000000..c33b1d0
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'Image Select',
+    'image_list' => 'Image List',
+    'image_details' => 'Image Details',
+    'image_upload' => 'Upload Image',
+    'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.',
+    'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.',
+    'image_all' => 'All',
+    'image_all_title' => 'View all images',
+    'image_book_title' => 'View images uploaded to this book',
+    'image_page_title' => 'View images uploaded to this page',
+    'image_search_hint' => 'Search by image name',
+    'image_uploaded' => 'Uploaded :uploadedDate',
+    'image_uploaded_by' => 'Uploaded by :userName',
+    'image_uploaded_to' => 'Uploaded to :pageLink',
+    'image_updated' => 'Updated :updateDate',
+    'image_load_more' => 'Load More',
+    'image_image_name' => 'Image Name',
+    'image_delete_used' => 'This image is used in the pages below.',
+    'image_delete_confirm_text' => 'Are you sure you want to delete this image?',
+    'image_select_image' => 'Select Image',
+    'image_dropzone' => 'Drop images or click here to upload',
+    'image_dropzone_drop' => 'Drop images here to upload',
+    'images_deleted' => 'Images Deleted',
+    'image_preview' => 'Image Preview',
+    'image_upload_success' => 'Image uploaded successfully',
+    'image_update_success' => 'Image details successfully updated',
+    'image_delete_success' => 'Image successfully deleted',
+    'image_replace' => 'Replace Image',
+    'image_replace_success' => 'Image file successfully updated',
+    'image_rebuild_thumbs' => 'Regenerate Size Variations',
+    'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!',
+
+    // Code Editor
+    'code_editor' => 'Edit Code',
+    'code_language' => 'Code Language',
+    'code_content' => 'Code Content',
+    'code_session_history' => 'Session History',
+    'code_save' => 'Save Code',
+];
diff --git a/lang/tk/editor.php b/lang/tk/editor.php
new file mode 100644 (file)
index 0000000..de9aa0e
--- /dev/null
@@ -0,0 +1,177 @@
+<?php
+/**
+ * Page Editor Lines
+ * Contains text strings used within the user interface of the
+ * WYSIWYG page editor. Some Markdown editor strings may still
+ * exist in the 'entities' file instead since this was added later.
+ */
+return [
+    // General editor terms
+    'general' => 'General',
+    'advanced' => 'Advanced',
+    'none' => 'None',
+    'cancel' => 'Cancel',
+    'save' => 'Save',
+    'close' => 'Close',
+    'undo' => 'Undo',
+    'redo' => 'Redo',
+    'left' => 'Left',
+    'center' => 'Center',
+    'right' => 'Right',
+    'top' => 'Top',
+    'middle' => 'Middle',
+    'bottom' => 'Bottom',
+    'width' => 'Width',
+    'height' => 'Height',
+    'More' => 'More',
+    'select' => 'Select...',
+
+    // Toolbar
+    'formats' => 'Formats',
+    'header_large' => 'Large Header',
+    'header_medium' => 'Medium Header',
+    'header_small' => 'Small Header',
+    'header_tiny' => 'Tiny Header',
+    'paragraph' => 'Paragraph',
+    'blockquote' => 'Blockquote',
+    'inline_code' => 'Inline code',
+    'callouts' => 'Callouts',
+    'callout_information' => 'Information',
+    'callout_success' => 'Success',
+    'callout_warning' => 'Warning',
+    'callout_danger' => 'Danger',
+    'bold' => 'Bold',
+    'italic' => 'Italic',
+    'underline' => 'Underline',
+    'strikethrough' => 'Strikethrough',
+    'superscript' => 'Superscript',
+    'subscript' => 'Subscript',
+    'text_color' => 'Text color',
+    'custom_color' => 'Custom color',
+    'remove_color' => 'Remove color',
+    'background_color' => 'Background color',
+    'align_left' => 'Align left',
+    'align_center' => 'Align center',
+    'align_right' => 'Align right',
+    'align_justify' => 'Justify',
+    'list_bullet' => 'Bullet list',
+    'list_numbered' => 'Numbered list',
+    'list_task' => 'Task list',
+    'indent_increase' => 'Increase indent',
+    'indent_decrease' => 'Decrease indent',
+    'table' => 'Table',
+    'insert_image' => 'Insert image',
+    'insert_image_title' => 'Insert/Edit Image',
+    'insert_link' => 'Insert/edit link',
+    'insert_link_title' => 'Insert/Edit Link',
+    'insert_horizontal_line' => 'Insert horizontal line',
+    'insert_code_block' => 'Insert code block',
+    'edit_code_block' => 'Edit code block',
+    'insert_drawing' => 'Insert/edit drawing',
+    'drawing_manager' => 'Drawing manager',
+    'insert_media' => 'Insert/edit media',
+    'insert_media_title' => 'Insert/Edit Media',
+    'clear_formatting' => 'Clear formatting',
+    'source_code' => 'Source code',
+    'source_code_title' => 'Source Code',
+    'fullscreen' => 'Fullscreen',
+    'image_options' => 'Image options',
+
+    // Tables
+    'table_properties' => 'Table properties',
+    'table_properties_title' => 'Table Properties',
+    'delete_table' => 'Delete table',
+    'table_clear_formatting' => 'Clear table formatting',
+    'resize_to_contents' => 'Resize to contents',
+    'row_header' => 'Row header',
+    'insert_row_before' => 'Insert row before',
+    'insert_row_after' => 'Insert row after',
+    'delete_row' => 'Delete row',
+    'insert_column_before' => 'Insert column before',
+    'insert_column_after' => 'Insert column after',
+    'delete_column' => 'Delete column',
+    'table_cell' => 'Cell',
+    'table_row' => 'Row',
+    'table_column' => 'Column',
+    'cell_properties' => 'Cell properties',
+    'cell_properties_title' => 'Cell Properties',
+    'cell_type' => 'Cell type',
+    'cell_type_cell' => 'Cell',
+    'cell_scope' => 'Scope',
+    'cell_type_header' => 'Header cell',
+    'merge_cells' => 'Merge cells',
+    'split_cell' => 'Split cell',
+    'table_row_group' => 'Row Group',
+    'table_column_group' => 'Column Group',
+    'horizontal_align' => 'Horizontal align',
+    'vertical_align' => 'Vertical align',
+    'border_width' => 'Border width',
+    'border_style' => 'Border style',
+    'border_color' => 'Border color',
+    'row_properties' => 'Row properties',
+    'row_properties_title' => 'Row Properties',
+    'cut_row' => 'Cut row',
+    'copy_row' => 'Copy row',
+    'paste_row_before' => 'Paste row before',
+    'paste_row_after' => 'Paste row after',
+    'row_type' => 'Row type',
+    'row_type_header' => 'Header',
+    'row_type_body' => 'Body',
+    'row_type_footer' => 'Footer',
+    'alignment' => 'Alignment',
+    'cut_column' => 'Cut column',
+    'copy_column' => 'Copy column',
+    'paste_column_before' => 'Paste column before',
+    'paste_column_after' => 'Paste column after',
+    'cell_padding' => 'Cell padding',
+    'cell_spacing' => 'Cell spacing',
+    'caption' => 'Caption',
+    'show_caption' => 'Show caption',
+    'constrain' => 'Constrain proportions',
+    'cell_border_solid' => 'Solid',
+    'cell_border_dotted' => 'Dotted',
+    'cell_border_dashed' => 'Dashed',
+    'cell_border_double' => 'Double',
+    'cell_border_groove' => 'Groove',
+    'cell_border_ridge' => 'Ridge',
+    'cell_border_inset' => 'Inset',
+    'cell_border_outset' => 'Outset',
+    'cell_border_none' => 'None',
+    'cell_border_hidden' => 'Hidden',
+
+    // Images, links, details/summary & embed
+    'source' => 'Source',
+    'alt_desc' => 'Alternative description',
+    'embed' => 'Embed',
+    'paste_embed' => 'Paste your embed code below:',
+    'url' => 'URL',
+    'text_to_display' => 'Text to display',
+    'title' => 'Title',
+    'open_link' => 'Open link',
+    'open_link_in' => 'Open link in...',
+    'open_link_current' => 'Current window',
+    'open_link_new' => 'New window',
+    'remove_link' => 'Remove link',
+    'insert_collapsible' => 'Insert collapsible block',
+    'collapsible_unwrap' => 'Unwrap',
+    'edit_label' => 'Edit label',
+    'toggle_open_closed' => 'Toggle open/closed',
+    'collapsible_edit' => 'Edit collapsible block',
+    'toggle_label' => 'Toggle label',
+
+    // About view
+    'about' => 'About the editor',
+    'about_title' => 'About the WYSIWYG Editor',
+    'editor_license' => 'Editor License & Copyright',
+    'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.',
+    'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.',
+    'save_continue' => 'Save Page & Continue',
+    'callouts_cycle' => '(Keep pressing to toggle through types)',
+    'link_selector' => 'Link to content',
+    'shortcuts' => 'Shortcuts',
+    'shortcut' => 'Shortcut',
+    'shortcuts_intro' => 'The following shortcuts are available in the editor:',
+    'windows_linux' => '(Windows/Linux)',
+    'mac' => '(Mac)',
+    'description' => 'Description',
+];
diff --git a/lang/tk/entities.php b/lang/tk/entities.php
new file mode 100644 (file)
index 0000000..35e6f05
--- /dev/null
@@ -0,0 +1,439 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'Recently Created',
+    'recently_created_pages' => 'Recently Created Pages',
+    'recently_updated_pages' => 'Recently Updated Pages',
+    'recently_created_chapters' => 'Recently Created Chapters',
+    'recently_created_books' => 'Recently Created Books',
+    'recently_created_shelves' => 'Recently Created Shelves',
+    'recently_update' => 'Recently Updated',
+    'recently_viewed' => 'Recently Viewed',
+    'recent_activity' => 'Recent Activity',
+    'create_now' => 'Create one now',
+    'revisions' => 'Revisions',
+    'meta_revision' => 'Revision #:revisionCount',
+    'meta_created' => 'Created :timeLength',
+    'meta_created_name' => 'Created :timeLength by :user',
+    'meta_updated' => 'Updated :timeLength',
+    'meta_updated_name' => 'Updated :timeLength by :user',
+    'meta_owned_name' => 'Owned by :user',
+    'meta_reference_count' => 'Referenced by :count item|Referenced by :count items',
+    'entity_select' => 'Entity Select',
+    'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item',
+    'images' => 'Images',
+    'my_recent_drafts' => 'My Recent Drafts',
+    'my_recently_viewed' => 'My Recently Viewed',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
+    'no_pages_viewed' => 'You have not viewed any pages',
+    'no_pages_recently_created' => 'No pages have been recently created',
+    'no_pages_recently_updated' => 'No pages have been recently updated',
+    'export' => 'Export',
+    'export_html' => 'Contained Web File',
+    'export_pdf' => 'PDF File',
+    'export_text' => 'Plain Text File',
+    'export_md' => 'Markdown File',
+    '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',
+
+    // Permissions and restrictions
+    'permissions' => 'Permissions',
+    'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.',
+    'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.',
+    'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.',
+    'permissions_save' => 'Save Permissions',
+    'permissions_owner' => 'Owner',
+    'permissions_role_everyone_else' => 'Everyone Else',
+    'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.',
+    'permissions_role_override' => 'Override permissions for role',
+    'permissions_inherit_defaults' => 'Inherit defaults',
+
+    // Search
+    'search_results' => 'Search Results',
+    'search_total_results_found' => ':count result found|:count total results found',
+    'search_clear' => 'Clear Search',
+    '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',
+
+    // Shelves
+    'shelf' => 'Shelf',
+    'shelves' => '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',
+
+    // 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.',
+    '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',
+
+    // 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',
+
+    // 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 Alpha 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_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',
+    '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_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!',
+    ],
+    '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',
+
+    // Editor Sidebar
+    'toggle_sidebar' => 'Toggle Sidebar',
+    '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',
+
+    // 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',
+
+    // Comments
+    'comment' => 'Comment',
+    'comments' => 'Comments',
+    'comment_add' => 'Add Comment',
+    'comment_placeholder' => 'Leave a comment here',
+    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    '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_delete_confirm' => 'Are you sure you want to delete this comment?',
+    'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
+
+    // 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.',
+
+    // 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.',
+
+    // 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?',
+
+    // 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.',
+
+    // 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',
+];
diff --git a/lang/tk/errors.php b/lang/tk/errors.php
new file mode 100644 (file)
index 0000000..9c40aa9
--- /dev/null
@@ -0,0 +1,121 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'You do not have permission to access the requested page.',
+    'permissionJson' => 'You do not have permission to perform the requested action.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'A user with the email :email already exists but with different credentials.',
+    '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.',
+
+    // 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.',
+
+    // 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.',
+
+    // Attachments
+    'attachment_not_found' => 'Attachment not found',
+    'attachment_upload_error' => 'An error occurred uploading the attachment file',
+
+    // 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',
+
+    // Entities
+    'entity_not_found' => 'Entity not found',
+    'bookshelf_not_found' => 'Shelf not found',
+    'book_not_found' => 'Book not found',
+    'page_not_found' => 'Page not found',
+    'chapter_not_found' => 'Chapter not found',
+    'selected_book_not_found' => 'The selected book was not found',
+    'selected_book_chapter_not_found' => 'The selected Book or Chapter was not found',
+    'guests_cannot_save_drafts' => 'Guests cannot save drafts',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'You cannot delete the only admin',
+    'users_cannot_delete_guest' => 'You cannot delete the guest user',
+    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+
+    // Roles
+    'role_cannot_be_edited' => 'This role cannot be edited',
+    'role_system_cannot_be_deleted' => 'This role is a system role and 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.',
+
+    // 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.',
+
+    // 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',
+    'error_occurred' => 'An Error Occurred',
+    'app_down' => ':appName is down right now',
+    'back_soon' => 'It will be back up soon.',
+
+    // 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',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
+
+    // HTTP errors
+    'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts',
+];
diff --git a/lang/tk/notifications.php b/lang/tk/notifications.php
new file mode 100644 (file)
index 0000000..1afd23f
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+/**
+ * Text used for activity-based notifications.
+ */
+return [
+
+    'new_comment_subject' => 'New comment on page: :pageName',
+    'new_comment_intro' => 'A user has commented on a page in :appName:',
+    'new_page_subject' => 'New page: :pageName',
+    'new_page_intro' => 'A new page has been created in :appName:',
+    'updated_page_subject' => 'Updated page: :pageName',
+    'updated_page_intro' => 'A page has been updated in :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.',
+
+    'detail_page_name' => 'Page Name:',
+    'detail_page_path' => 'Page Path:',
+    'detail_commenter' => 'Commenter:',
+    'detail_comment' => 'Comment:',
+    'detail_created_by' => 'Created By:',
+    'detail_updated_by' => 'Updated By:',
+
+    'action_view_comment' => 'View Comment',
+    'action_view_page' => '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',
+];
diff --git a/lang/tk/pagination.php b/lang/tk/pagination.php
new file mode 100644 (file)
index 0000000..85bd12f
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+/**
+ * Pagination Language Lines
+ * The following language lines are used by the paginator library to build
+ * the simple pagination links.
+ */
+return [
+
+    'previous' => '&laquo; Previous',
+    'next'     => 'Next &raquo;',
+
+];
diff --git a/lang/tk/passwords.php b/lang/tk/passwords.php
new file mode 100644 (file)
index 0000000..b408f3c
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Password Reminder Language Lines
+ * The following language lines are the default lines which match reasons
+ * that are given by the password broker for a password update attempt has failed.
+ */
+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!',
+
+];
diff --git a/lang/tk/preferences.php b/lang/tk/preferences.php
new file mode 100644 (file)
index 0000000..2872f5f
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * Text used for user-preference specific views within bookstack.
+ */
+
+return [
+    '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.',
+
+    '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_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.',
+
+    '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!',
+
+    '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.',
+
+    '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?',
+];
diff --git a/lang/tk/settings.php b/lang/tk/settings.php
new file mode 100644 (file)
index 0000000..5427cb9
--- /dev/null
@@ -0,0 +1,338 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Settings',
+    'settings_save' => 'Save Settings',
+    'system_version' => 'System Version',
+    'categories' => 'Categories',
+
+    // App Settings
+    'app_customization' => 'Customization',
+    'app_features_security' => 'Features & Security',
+    'app_name' => 'Application Name',
+    'app_name_desc' => 'This name is shown in the header and in any system-sent emails.',
+    'app_name_header' => 'Show name in header',
+    'app_public_access' => 'Public Access',
+    'app_public_access_desc' => 'Enabling this option will allow visitors, that are not logged-in, to access content in your BookStack instance.',
+    'app_public_access_desc_guest' => 'Access for public visitors can be controlled through the "Guest" user.',
+    'app_public_access_toggle' => 'Allow public access',
+    'app_public_viewing' => 'Allow public viewing?',
+    'app_secure_images' => 'Higher Security Image Uploads',
+    'app_secure_images_toggle' => 'Enable higher security image uploads',
+    'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.',
+    'app_default_editor' => 'Default Page Editor',
+    'app_default_editor_desc' => 'Select which editor will be used by default when editing new pages. This can be overridden at a page level where permissions allow.',
+    'app_custom_html' => 'Custom HTML Head Content',
+    'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the <head> section of every page. This is handy for overriding styles or adding analytics code.',
+    'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.',
+    'app_logo' => 'Application Logo',
+    'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
+    'app_icon' => 'Application Icon',
+    'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
+    'app_homepage' => 'Application Homepage',
+    'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
+    'app_homepage_select' => 'Select a page',
+    'app_footer_links' => 'Footer Links',
+    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
+    'app_footer_links_label' => 'Link Label',
+    'app_footer_links_url' => 'Link URL',
+    'app_footer_links_add' => 'Add Footer Link',
+    'app_disable_comments' => 'Disable Comments',
+    'app_disable_comments_toggle' => 'Disable comments',
+    'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.',
+
+    // Color settings
+    'color_scheme' => 'Application Color Scheme',
+    'color_scheme_desc' => 'Set the colors to use in the application user interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
+    'ui_colors_desc' => 'Set the application primary color and default link color. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the application interface.',
+    'app_color' => 'Primary Color',
+    'link_color' => 'Default Link Color',
+    'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
+    'bookshelf_color' => 'Shelf Color',
+    'book_color' => 'Book Color',
+    'chapter_color' => 'Chapter Color',
+    'page_color' => 'Page Color',
+    'page_draft_color' => 'Page Draft Color',
+
+    // Registration Settings
+    'reg_settings' => 'Registration',
+    'reg_enable' => 'Enable Registration',
+    'reg_enable_toggle' => 'Enable registration',
+    'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.',
+    'reg_default_role' => 'Default user role after registration',
+    'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.',
+    'reg_email_confirmation' => 'Email Confirmation',
+    'reg_email_confirmation_toggle' => 'Require email confirmation',
+    'reg_confirm_email_desc' => 'If domain restriction is used then email confirmation will be required and this option will be ignored.',
+    'reg_confirm_restrict_domain' => 'Domain Restriction',
+    'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application. <br> Note that users will be able to change their email addresses after successful registration.',
+    'reg_confirm_restrict_domain_placeholder' => 'No restriction set',
+
+    // Maintenance settings
+    'maint' => 'Maintenance',
+    'maint_image_cleanup' => 'Cleanup Images',
+    'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.',
+    'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
+    'maint_image_cleanup_run' => 'Run Cleanup',
+    'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?',
+    '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.',
+
+    // 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.',
+
+    // 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_deleted_item' => 'Deleted Item',
+    'audit_deleted_item_name' => 'Name: :name',
+    'audit_table_user' => 'User',
+    'audit_table_event' => 'Event',
+    'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'Activity Date',
+    'audit_date_from' => 'Date Range From',
+    'audit_date_to' => 'Date Range To',
+
+    // Role Settings
+    'roles' => '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_delete_confirm' => 'This will delete the role with the name \':roleName\'.',
+    'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.',
+    'role_delete_no_migration' => "Don't migrate users",
+    'role_delete_sure' => 'Are you sure you want to delete this role?',
+    'role_edit' => 'Edit Role',
+    'role_details' => 'Role Details',
+    'role_name' => 'Role Name',
+    'role_desc' => 'Short Description of Role',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    '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_editor_change' => 'Change page editor',
+    'role_notifications' => 'Receive & manage notifications',
+    '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_all' => 'All',
+    'role_own' => 'Own',
+    'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to',
+    'role_save' => 'Save Role',
+    'role_users' => 'Users in this role',
+    'role_users_none' => 'No users are currently assigned to this role',
+
+    // 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',
+
+    // 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?',
+
+    // 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:',
+
+    // 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',
+
+    //! 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)',
+        '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/tk/validation.php b/lang/tk/validation.php
new file mode 100644 (file)
index 0000000..2a676c7
--- /dev/null
@@ -0,0 +1,117 @@
+<?php
+/**
+ * Validation Lines
+ * The following language lines contain the default error messages used by
+ * the validator class. Some of these rules have multiple versions such
+ * as the size rules. Feel free to tweak each of these messages here.
+ */
+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.',
+    'array'                => 'The :attribute must be an array.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'before'               => 'The :attribute must be a date before :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.',
+    ],
+    '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.',
+    '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.',
+    ],
+    '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.',
+    ],
+    '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.',
+    '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.',
+    ],
+    '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.',
+    ],
+    '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.',
+    ],
+    'mimes'                => 'The :attribute must be a file of type: :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.',
+    ],
+    '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.',
+    '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.',
+    ],
+    '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.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Password confirmation required',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
index d384e3924473ff8a499c891bd6875a2c30b604de..aed4880f1242b4a32b7a263cc0b65e497ed11ef8 100644 (file)
@@ -109,5 +109,5 @@ return [
     'terms_of_service' => 'Умови використання',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Шукати :appName',
 ];
index 664c6ed1725ea84c135fb3775b8ff508a1c41af8..01549e7f36f6f461ec528ca252cac420ab622b58 100644 (file)
@@ -224,8 +224,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(Очистити вміст)',
     'pages_edit_switch_to_markdown_stable' => '(Стабілізувати вміст)',
     'pages_edit_switch_to_wysiwyg' => 'Змінити редактор на WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    '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' => 'Введіть список змін',
index 602df64f8f5bbdac49f076a0bfca1eb77b347c77..7698093c1d509ba3210c99986d47f008ccd990e3 100644 (file)
@@ -78,7 +78,7 @@ return [
     // 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' => 'Цю роль не можна редагувати',
index fda4899b36817c25b37e0275bb3054c64cb760a6..d11de40e4a6299a492e6aca03081ac7144a2d158 100644 (file)
@@ -85,7 +85,7 @@ return [
     'webhook_delete_notification' => 'Webhook đã được xóa thành công',
 
     // Users
-    'user_create' => 'người dùng đã tạo',
+    'user_create' => 'đã tạo người dùng',
     'user_create_notification' => 'Người dùng được tạo thành công',
     'user_update' => 'người dùng được cập nhật',
     'user_update_notification' => 'Người dùng được cập nhật thành công',
index c726920eaff60aefd5d2a2d43797bae3a6db507b..f8f6ca293bbcac11d84903f29493b486c0b14f97 100644 (file)
@@ -107,7 +107,7 @@ return [
     '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_active' => 'Quyền của kệ đang hoạt động',
     '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' => 'Sao chép các quyền cho sách',
@@ -207,7 +207,7 @@ return [
     'pages_delete_draft' => 'Xóa Trang Nháp',
     'pages_delete_success' => 'Đã xóa Trang',
     'pages_delete_draft_success' => 'Đã xóa trang Nháp',
-    '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' => '.',
     'pages_delete_confirm' => 'Bạn có chắc chắn muốn xóa trang này?',
     'pages_delete_draft_confirm' => 'Bạn có chắc chắn muốn xóa trang nháp này?',
     'pages_editing_named' => 'Đang chỉnh sửa Trang :pageName',
@@ -223,7 +223,7 @@ return [
     '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_wysiwyg' => 'Chuyển sang trình soạn thảo WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
     'pages_edit_set_changelog' => 'Đặt Changelog',
@@ -232,7 +232,7 @@ return [
     '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_a' => 'Sau khi lưu, tùy chọn trình soạn thảo mới sẽ được sử dụng bởi bất kỳ trình chỉnh sửa nào trong tương lai, kể cả những người không thể tự thay đổi loại trình chỉnh sửa.',
     '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' => 'Lưu Trang',
index 71ba5acadf8726e4bfc2ef3949f71f1aeb8d01ce..6651f13966c533f0bd870c6f56169a80dc6226a1 100644 (file)
@@ -103,7 +103,7 @@ return [
     'totp'                 => '提供的代碼無效或已過期。',
     'unique'               => ':attribute 已經被使用。',
     'url'                  => ':attribute 格式無效。',
-    'uploaded'             => '無法上傳文件, 服務器可能不接受此大小的文件。',
+    'uploaded'             => '無法上傳文檔案, 伺服器可能不接受此大小的檔案。',
 
     // Custom validation lines
     'custom' => [
diff --git a/resources/js/app.js b/resources/js/app.js
deleted file mode 100644 (file)
index 5f4902f..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-import {EventManager} from './services/events.ts';
-import {HttpManager} from './services/http.ts';
-import {Translator} from './services/translations.ts';
-import * as componentMap from './components';
-import {ComponentStore} from './services/components.ts';
-
-// eslint-disable-next-line no-underscore-dangle
-window.__DEV__ = false;
-
-// Url retrieval function
-window.baseUrl = function baseUrl(path) {
-    let targetPath = path;
-    let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content');
-    if (basePath[basePath.length - 1] === '/') basePath = basePath.slice(0, basePath.length - 1);
-    if (targetPath[0] === '/') targetPath = targetPath.slice(1);
-    return `${basePath}/${targetPath}`;
-};
-
-window.importVersioned = function importVersioned(moduleName) {
-    const version = document.querySelector('link[href*="/dist/styles.css?version="]').href.split('?version=').pop();
-    const importPath = window.baseUrl(`dist/${moduleName}.js?version=${version}`);
-    return import(importPath);
-};
-
-// Set events, http & translation services on window
-window.$http = new HttpManager();
-window.$events = new EventManager();
-window.$trans = new Translator();
-
-// Load & initialise components
-window.$components = new ComponentStore();
-window.$components.register(componentMap);
-window.$components.init();
diff --git a/resources/js/app.ts b/resources/js/app.ts
new file mode 100644 (file)
index 0000000..141a50e
--- /dev/null
@@ -0,0 +1,23 @@
+import {EventManager} from './services/events';
+import {HttpManager} from './services/http';
+import {Translator} from './services/translations';
+import * as componentMap from './components/index';
+import {ComponentStore} from './services/components';
+import {baseUrl, importVersioned} from "./services/util";
+
+// eslint-disable-next-line no-underscore-dangle
+window.__DEV__ = false;
+
+// Make common important util functions global
+window.baseUrl = baseUrl;
+window.importVersioned = importVersioned;
+
+// Setup events, http & translation services
+window.$http = new HttpManager();
+window.$events = new EventManager();
+window.$trans = new Translator();
+
+// Load & initialise components
+window.$components = new ComponentStore();
+window.$components.register(componentMap);
+window.$components.init();
index 3213c4835aa45fd5bad209e34b4b5a9e6b44967c..e7de15ae5fa03bd63a720508638cb91663374712 100644 (file)
@@ -1,5 +1,5 @@
-import {onChildEvent} from '../services/dom';
-import {uniqueId} from '../services/util';
+import {onChildEvent} from '../services/dom.ts';
+import {uniqueId} from '../services/util.ts';
 import {Component} from './component';
 
 /**
index aa2801f19e666c52ee9f1d738b71478ec105552a..6ed3deedf4dd6ed9e16d7f77b9a5edaa54bded8a 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 export class AjaxDeleteRow extends Component {
index 583dde5724424defb44906504ab4973a2fb88af5..de1a6db43a7e83cad11b8d783fb3db9be9de01fd 100644 (file)
@@ -1,4 +1,4 @@
-import {onEnterPress, onSelect} from '../services/dom';
+import {onEnterPress, onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 /**
index f45b25e36d05f92d96d175e8b5207020bdcd4d14..2dc7313a880901014a08c2e2f9c7a163030ab2ea 100644 (file)
@@ -1,4 +1,4 @@
-import {showLoading} from '../services/dom';
+import {showLoading} from '../services/dom.ts';
 import {Component} from './component';
 
 export class Attachments extends Component {
index 2eede241c694dc323f53ff33070163de2a79cea4..0b828e71bd1ea35976f348d02fd1c31dca2c5de6 100644 (file)
@@ -1,7 +1,7 @@
-import {escapeHtml} from '../services/util';
-import {onChildEvent} from '../services/dom';
+import {escapeHtml} from '../services/util.ts';
+import {onChildEvent} from '../services/dom.ts';
 import {Component} from './component';
-import {KeyboardNavigationHandler} from '../services/keyboard-navigation';
+import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
 
 const ajaxCache = {};
 
index 2ba7d5d36b00dc912be8c3d1f25f1b5e464d2ffe..48557141f6f7d80e66c501bfe9655340694566f0 100644 (file)
@@ -1,6 +1,6 @@
 import Sortable, {MultiDrag} from 'sortablejs';
 import {Component} from './component';
-import {htmlToDom} from '../services/dom';
+import {htmlToDom} from '../services/dom.ts';
 
 // Auto sort control
 const sortOperations = {
index 7c6480a1af0b8a6c176e1f6dd8beca9ada8a5033..6b0707bdd0570b4acbae3d32a7cf23f2864232ba 100644 (file)
@@ -1,4 +1,4 @@
-import {slideUp, slideDown} from '../services/animations';
+import {slideUp, slideDown} from '../services/animations.ts';
 import {Component} from './component';
 
 export class ChapterContents extends Component {
index 091c3483f4d6bd1ecffabb7f52f3f0b17933abfa..12937d47293b31effb7e2766d68af08598834899 100644 (file)
@@ -1,4 +1,4 @@
-import {onChildEvent, onEnterPress, onSelect} from '../services/dom';
+import {onChildEvent, onEnterPress, onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 export class CodeEditor extends Component {
index 6f740ed7163204020fcbd6569393b14729d787c4..7b6fa79fb3bcef4ab8c13b59090711754efac7c9 100644 (file)
@@ -1,4 +1,4 @@
-import {slideDown, slideUp} from '../services/animations';
+import {slideDown, slideUp} from '../services/animations.ts';
 import {Component} from './component';
 
 /**
index 184618fccfad9928cca02f81f3450b61fbe21ebe..00f3cfed201c34016da201a0fca14afb6c07e37c 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 /**
index 2344619f5e9b794de3177c6c4acf8a5c7550b351..fcbabc022a7c21395272c092e175fb968d773ab2 100644 (file)
@@ -1,5 +1,5 @@
-import {debounce} from '../services/util';
-import {transitionHeight} from '../services/animations';
+import {debounce} from '../services/util.ts';
+import {transitionHeight} from '../services/animations.ts';
 import {Component} from './component';
 
 export class DropdownSearch extends Component {
index 4efd428acf72b408dc4867e94a203173ed06c049..5dd5dd93b013023ebf466ef021e9237dd1b57ce7 100644 (file)
@@ -1,5 +1,5 @@
-import {onSelect} from '../services/dom';
-import {KeyboardNavigationHandler} from '../services/keyboard-navigation';
+import {onSelect} from '../services/dom.ts';
+import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
 import {Component} from './component';
 
 /**
index 920fe875f22135de5a302dee0a8e44d7207aaf86..598e0d8d48b685cdb58a9782d5addc4eafa64732 100644 (file)
@@ -2,7 +2,7 @@ import {Component} from './component';
 import {Clipboard} from '../services/clipboard.ts';
 import {
     elem, getLoading, onSelect, removeLoading,
-} from '../services/dom';
+} from '../services/dom.ts';
 
 export class Dropzone extends Component {
 
index 7ab99a2a70bb2f45445d485b6aff49e018b25d7b..b020c5d85ba8f785785df1e2f98e2607f0e9b385 100644 (file)
@@ -1,4 +1,4 @@
-import {htmlToDom} from '../services/dom';
+import {htmlToDom} from '../services/dom.ts';
 import {Component} from './component';
 
 export class EntityPermissions extends Component {
index 7a50444708dee6313ab5b2caee5a2dd21def7cdb..9d45133266d7e4095219970ecc22361d9368aa2d 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 export class EntitySearch extends Component {
index 561370d7a342004dacc592e81d5366a07e1d00a9..7491119a137ffbb24d0d83f0f4be1ec46add9ac7 100644 (file)
@@ -1,4 +1,4 @@
-import {onChildEvent} from '../services/dom';
+import {onChildEvent} from '../services/dom.ts';
 import {Component} from './component';
 
 /**
index 2097c0528868181cdc9e94c67cc630fdfca18652..f722a25e71b75faebb784b0ddbc044af3099fd76 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 /**
index 0d2018b9da23531b27d98b18db682335ab47615b..29173a058293d99f88ed25390892961ea39e92e1 100644 (file)
@@ -1,4 +1,4 @@
-import {slideUp, slideDown} from '../services/animations';
+import {slideUp, slideDown} from '../services/animations.ts';
 import {Component} from './component';
 
 export class ExpandToggle extends Component {
index 798bd7aacb0d5c00fb5d0ece92c6ae604678a235..2cdaf591ac458d99c13c5d3f8638d86a52ef3141 100644 (file)
@@ -1,6 +1,6 @@
-import {htmlToDom} from '../services/dom';
-import {debounce} from '../services/util';
-import {KeyboardNavigationHandler} from '../services/keyboard-navigation';
+import {htmlToDom} from '../services/dom.ts';
+import {debounce} from '../services/util.ts';
+import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
 import {Component} from './component';
 
 /**
index 47231477b684fc00e1d453bbc5a83d88a3188500..c8108ab28c19f6b456e419a50833ae8cd98f9b34 100644 (file)
@@ -1,6 +1,6 @@
 import {
     onChildEvent, onSelect, removeLoading, showLoading,
-} from '../services/dom';
+} from '../services/dom.ts';
 import {Component} from './component';
 
 export class ImageManager extends Component {
similarity index 98%
rename from resources/js/components/index.js
rename to resources/js/components/index.ts
index 8ad5e14cb2e41b6c89570c6b11714a819589f181..24e60bd97f242a3bbc7bd7e0eccd68864b451229 100644 (file)
@@ -30,6 +30,7 @@ export {HeaderMobileToggle} from './header-mobile-toggle';
 export {ImageManager} from './image-manager';
 export {ImagePicker} from './image-picker';
 export {ListSortControl} from './list-sort-control';
+export {LoadingButton} from './loading-button.ts';
 export {MarkdownEditor} from './markdown-editor';
 export {NewUserPassword} from './new-user-password';
 export {Notification} from './notification';
diff --git a/resources/js/components/loading-button.ts b/resources/js/components/loading-button.ts
new file mode 100644 (file)
index 0000000..a793d30
--- /dev/null
@@ -0,0 +1,38 @@
+import {Component} from "./component.js";
+import {showLoading} from "../services/dom";
+import {el} from "../wysiwyg/utils/dom";
+
+/**
+ * Loading button.
+ * Shows a loading indicator and disables the button when the button is clicked,
+ * or when the form attached to the button is submitted.
+ */
+export class LoadingButton extends Component {
+
+    protected button!: HTMLButtonElement;
+    protected loadingEl: HTMLDivElement|null = null;
+
+    setup() {
+        this.button = this.$el as HTMLButtonElement;
+        const form = this.button.form;
+
+        const action = () => {
+            setTimeout(() => this.showLoadingState(), 10)
+        };
+
+        this.button.addEventListener('click', action);
+        if (form) {
+            form.addEventListener('submit', action);
+        }
+    }
+
+    showLoadingState() {
+        this.button.disabled = true;
+
+        if (!this.loadingEl) {
+            this.loadingEl = el('div', {class: 'inline block'}) as HTMLDivElement;
+            showLoading(this.loadingEl);
+            this.button.after(this.loadingEl);
+        }
+    }
+}
\ No newline at end of file
index 64cee12cd29b6a212a2fb882be6a0be193598a46..1b133047d00d1d23d2bec486ee5ca7a84beb0af9 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 export class OptionalInput extends Component {
index fd8ad1f2e47b0ac5a5c6494f7ffcc9c5b2df5d8e..8c0a8b33e5406a4659a8ff7cfe97407a1acc6df8 100644 (file)
@@ -1,5 +1,5 @@
 import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom';
+import {getLoading, htmlToDom} from '../services/dom.ts';
 import {buildForInput} from '../wysiwyg-tinymce/config';
 
 export class PageComment extends Component {
index 1d6abfe2044ff3a0a9e006f29e04fb4fef94601c..8f023836b090876360c120df95e3c1f0a137bf18 100644 (file)
@@ -1,5 +1,5 @@
 import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom';
+import {getLoading, htmlToDom} from '../services/dom.ts';
 import {buildForInput} from '../wysiwyg-tinymce/config';
 
 export class PageComments extends Component {
@@ -93,7 +93,6 @@ export class PageComments extends Component {
 
     updateCount() {
         const count = this.getCommentCount();
-        console.log('update count', count, this.container);
         this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count});
     }
 
index 1e13ae38800ebf6cd86a588cbe7e07ccc0957200..d3ac78a4ad19347779583d1331cb470d6532959a 100644 (file)
@@ -1,5 +1,5 @@
-import * as DOM from '../services/dom';
-import {scrollAndHighlightElement} from '../services/util';
+import * as DOM from '../services/dom.ts';
+import {scrollAndHighlightElement} from '../services/util.ts';
 import {Component} from './component';
 
 function toggleAnchorHighlighting(elementId, shouldHighlight) {
index 2160675529bd5eef9a132141af2f7f9ee853dc01..7ffceb0f9048a281f53ae82dfe31732e52d17247 100644 (file)
@@ -1,5 +1,5 @@
-import {onSelect} from '../services/dom';
-import {debounce} from '../services/util';
+import {onSelect} from '../services/dom.ts';
+import {debounce} from '../services/util.ts';
 import {Component} from './component';
 import {utcTimeStampToLocalTime} from '../services/dates.ts';
 
index 607576cb9f9595a6cb9382c44e6c8a7f6002cba4..292b923e5519f47742990918214c7f46b7e9140b 100644 (file)
@@ -1,4 +1,4 @@
-import * as DOM from '../services/dom';
+import * as DOM from '../services/dom.ts';
 import {Component} from './component';
 import {copyTextToClipboard} from '../services/clipboard.ts';
 
index 6627365483e69f6c90b37d5eac7528f1f0c71be3..6bd8f9c722b7044b9ae9ab5cd73867e8383a5434 100644 (file)
@@ -1,5 +1,5 @@
-import {fadeIn, fadeOut} from '../services/animations';
-import {onSelect} from '../services/dom';
+import {fadeIn, fadeOut} from '../services/animations.ts';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 /**
index 56ec876d48bbb66a78e65038e4bfdd45a09759d5..cf81990ab3f934539bdc104a3226d2575e745d9e 100644 (file)
@@ -1,4 +1,4 @@
-import * as DOM from '../services/dom';
+import * as DOM from '../services/dom.ts';
 import {Component} from './component';
 
 export class TemplateManager extends Component {
index e6adc3c23c82d4e163c12c4f4c42adfc1d80d249..f9ec03ed366a4c6ea4bd643dea0ab7e524ff5ae7 100644 (file)
@@ -1,4 +1,4 @@
-import {onChildEvent} from '../services/dom';
+import {onChildEvent} from '../services/dom.ts';
 import {Component} from './component';
 
 export class UserSelect extends Component {
index e505c96e0d4a24f1a5d83fcee75a9e1b70962273..b637c97c1b92a7cf55dd3556c427d4e0e1992e54 100644 (file)
@@ -7,10 +7,12 @@ declare global {
     const __DEV__: boolean;
 
     interface Window {
+        __DEV__: boolean;
         $components: ComponentStore;
         $events: EventManager;
         $trans: Translator;
         $http: HttpManager;
         baseUrl: (path: string) => string;
+        importVersioned: (module: string) => Promise<object>;
     }
 }
\ No newline at end of file
index a6332cbb844fddc44d022e9708b0051fca5b41e3..664767605b8a91b33125512efc96476068da4c32 100644 (file)
@@ -1,5 +1,5 @@
 import {provideKeyBindings} from './shortcuts';
-import {debounce} from '../services/util';
+import {debounce} from '../services/util.ts';
 import {Clipboard} from '../services/clipboard.ts';
 
 /**
similarity index 63%
rename from resources/js/services/animations.js
rename to resources/js/services/animations.ts
index bc983c8072aa529d7693018716c7b0ed255db087..adf4cb3c932f66afb140fbcd66386dbdb6adeb40 100644 (file)
@@ -1,30 +1,30 @@
 /**
  * Used in the function below to store references of clean-up functions.
  * Used to ensure only one transitionend function exists at any time.
- * @type {WeakMap<object, any>}
  */
-const animateStylesCleanupMap = new WeakMap();
+const animateStylesCleanupMap: WeakMap<object, any> = new WeakMap();
 
 /**
  * Animate the css styles of an element using FLIP animation techniques.
- * Styles must be an object where the keys are style properties, camelcase, and the values
+ * Styles must be an object where the keys are style rule names and the values
  * are an array of two items in the format [initialValue, finalValue]
- * @param {Element} element
- * @param {Object} styles
- * @param {Number} animTime
- * @param {Function} onComplete
  */
-function animateStyles(element, styles, animTime = 400, onComplete = null) {
+function animateStyles(
+    element: HTMLElement,
+    styles: Record<string, string[]>,
+    animTime: number = 400,
+    onComplete: Function | null = null
+): void {
     const styleNames = Object.keys(styles);
     for (const style of styleNames) {
-        element.style[style] = styles[style][0];
+        element.style.setProperty(style, styles[style][0]);
     }
 
     const cleanup = () => {
         for (const style of styleNames) {
-            element.style[style] = null;
+            element.style.removeProperty(style);
         }
-        element.style.transition = null;
+        element.style.removeProperty('transition');
         element.removeEventListener('transitionend', cleanup);
         animateStylesCleanupMap.delete(element);
         if (onComplete) onComplete();
@@ -33,7 +33,7 @@ function animateStyles(element, styles, animTime = 400, onComplete = null) {
     setTimeout(() => {
         element.style.transition = `all ease-in-out ${animTime}ms`;
         for (const style of styleNames) {
-            element.style[style] = styles[style][1];
+            element.style.setProperty(style, styles[style][1]);
         }
 
         element.addEventListener('transitionend', cleanup);
@@ -43,9 +43,8 @@ function animateStyles(element, styles, animTime = 400, onComplete = null) {
 
 /**
  * Run the active cleanup action for the given element.
- * @param {Element} element
  */
-function cleanupExistingElementAnimation(element) {
+function cleanupExistingElementAnimation(element: Element) {
     if (animateStylesCleanupMap.has(element)) {
         const oldCleanup = animateStylesCleanupMap.get(element);
         oldCleanup();
@@ -54,15 +53,12 @@ function cleanupExistingElementAnimation(element) {
 
 /**
  * Fade in the given element.
- * @param {Element} element
- * @param {Number} animTime
- * @param {Function|null} onComplete
  */
-export function fadeIn(element, animTime = 400, onComplete = null) {
+export function fadeIn(element: HTMLElement, animTime: number = 400, onComplete: Function | null = null): void {
     cleanupExistingElementAnimation(element);
     element.style.display = 'block';
     animateStyles(element, {
-        opacity: ['0', '1'],
+        'opacity': ['0', '1'],
     }, animTime, () => {
         if (onComplete) onComplete();
     });
@@ -70,14 +66,11 @@ export function fadeIn(element, animTime = 400, onComplete = null) {
 
 /**
  * Fade out the given element.
- * @param {Element} element
- * @param {Number} animTime
- * @param {Function|null} onComplete
  */
-export function fadeOut(element, animTime = 400, onComplete = null) {
+export function fadeOut(element: HTMLElement, animTime: number = 400, onComplete: Function | null = null): void {
     cleanupExistingElementAnimation(element);
     animateStyles(element, {
-        opacity: ['1', '0'],
+        'opacity': ['1', '0'],
     }, animTime, () => {
         element.style.display = 'none';
         if (onComplete) onComplete();
@@ -86,20 +79,18 @@ export function fadeOut(element, animTime = 400, onComplete = null) {
 
 /**
  * Hide the element by sliding the contents upwards.
- * @param {Element} element
- * @param {Number} animTime
  */
-export function slideUp(element, animTime = 400) {
+export function slideUp(element: HTMLElement, animTime: number = 400) {
     cleanupExistingElementAnimation(element);
     const currentHeight = element.getBoundingClientRect().height;
     const computedStyles = getComputedStyle(element);
     const currentPaddingTop = computedStyles.getPropertyValue('padding-top');
     const currentPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
     const animStyles = {
-        maxHeight: [`${currentHeight}px`, '0px'],
-        overflow: ['hidden', 'hidden'],
-        paddingTop: [currentPaddingTop, '0px'],
-        paddingBottom: [currentPaddingBottom, '0px'],
+        'max-height': [`${currentHeight}px`, '0px'],
+        'overflow': ['hidden', 'hidden'],
+        'padding-top': [currentPaddingTop, '0px'],
+        'padding-bottom': [currentPaddingBottom, '0px'],
     };
 
     animateStyles(element, animStyles, animTime, () => {
@@ -109,10 +100,8 @@ export function slideUp(element, animTime = 400) {
 
 /**
  * Show the given element by expanding the contents.
- * @param {Element} element - Element to animate
- * @param {Number} animTime - Animation time in ms
  */
-export function slideDown(element, animTime = 400) {
+export function slideDown(element: HTMLElement, animTime: number = 400) {
     cleanupExistingElementAnimation(element);
     element.style.display = 'block';
     const targetHeight = element.getBoundingClientRect().height;
@@ -120,10 +109,10 @@ export function slideDown(element, animTime = 400) {
     const targetPaddingTop = computedStyles.getPropertyValue('padding-top');
     const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
     const animStyles = {
-        maxHeight: ['0px', `${targetHeight}px`],
-        overflow: ['hidden', 'hidden'],
-        paddingTop: ['0px', targetPaddingTop],
-        paddingBottom: ['0px', targetPaddingBottom],
+        'max-height': ['0px', `${targetHeight}px`],
+        'overflow': ['hidden', 'hidden'],
+        'padding-top': ['0px', targetPaddingTop],
+        'padding-bottom': ['0px', targetPaddingBottom],
     };
 
     animateStyles(element, animStyles, animTime);
@@ -134,11 +123,8 @@ export function slideDown(element, animTime = 400) {
  * Call with first state, and you'll receive a function in return.
  * Call the returned function in the second state to animate between those two states.
  * If animating to/from 0-height use the slide-up/slide down as easier alternatives.
- * @param {Element} element - Element to animate
- * @param {Number} animTime - Animation time in ms
- * @returns {function} - Function to run in second state to trigger animation.
  */
-export function transitionHeight(element, animTime = 400) {
+export function transitionHeight(element: HTMLElement, animTime: number = 400): () => void {
     const startHeight = element.getBoundingClientRect().height;
     const initialComputedStyles = getComputedStyle(element);
     const startPaddingTop = initialComputedStyles.getPropertyValue('padding-top');
@@ -151,10 +137,10 @@ export function transitionHeight(element, animTime = 400) {
         const targetPaddingTop = computedStyles.getPropertyValue('padding-top');
         const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
         const animStyles = {
-            height: [`${startHeight}px`, `${targetHeight}px`],
-            overflow: ['hidden', 'hidden'],
-            paddingTop: [startPaddingTop, targetPaddingTop],
-            paddingBottom: [startPaddingBottom, targetPaddingBottom],
+            'height': [`${startHeight}px`, `${targetHeight}px`],
+            'overflow': ['hidden', 'hidden'],
+            'padding-top': [startPaddingTop, targetPaddingTop],
+            'padding-bottom': [startPaddingBottom, targetPaddingBottom],
         };
 
         animateStyles(element, animStyles, animTime);
similarity index 63%
rename from resources/js/services/dom.js
rename to resources/js/services/dom.ts
index bcfd0b565da2ab9b9144af2243d2cdb2fe13cc50..c88827bac40a1788b89295152799b62e9871425f 100644 (file)
@@ -1,12 +1,15 @@
+/**
+ * Check if the given param is a HTMLElement
+ */
+export function isHTMLElement(el: any): el is HTMLElement {
+    return el instanceof HTMLElement;
+}
+
 /**
  * Create a new element with the given attrs and children.
  * Children can be a string for text nodes or other elements.
- * @param {String} tagName
- * @param {Object<String, String>} attrs
- * @param {Element[]|String[]}children
- * @return {*}
  */
-export function elem(tagName, attrs = {}, children = []) {
+export function elem(tagName: string, attrs: Record<string, string> = {}, children: Element[]|string[] = []): HTMLElement {
     const el = document.createElement(tagName);
 
     for (const [key, val] of Object.entries(attrs)) {
@@ -30,10 +33,8 @@ export function elem(tagName, attrs = {}, children = []) {
 
 /**
  * Run the given callback against each element that matches the given selector.
- * @param {String} selector
- * @param {Function<Element>} callback
  */
-export function forEach(selector, callback) {
+export function forEach(selector: string, callback: (el: Element) => any) {
     const elements = document.querySelectorAll(selector);
     for (const element of elements) {
         callback(element);
@@ -42,11 +43,8 @@ export function forEach(selector, callback) {
 
 /**
  * Helper to listen to multiple DOM events
- * @param {Element} listenerElement
- * @param {Array<String>} events
- * @param {Function<Event>} callback
  */
-export function onEvents(listenerElement, events, callback) {
+export function onEvents(listenerElement: Element, events: string[], callback: (e: Event) => any): void {
     for (const eventName of events) {
         listenerElement.addEventListener(eventName, callback);
     }
@@ -55,10 +53,8 @@ export function onEvents(listenerElement, events, callback) {
 /**
  * Helper to run an action when an element is selected.
  * A "select" is made to be accessible, So can be a click, space-press or enter-press.
- * @param {HTMLElement|Array} elements
- * @param {function} callback
  */
-export function onSelect(elements, callback) {
+export function onSelect(elements: HTMLElement|HTMLElement[], callback: (e: Event) => any): void {
     if (!Array.isArray(elements)) {
         elements = [elements];
     }
@@ -76,16 +72,13 @@ export function onSelect(elements, callback) {
 
 /**
  * Listen to key press on the given element(s).
- * @param {String} key
- * @param {HTMLElement|Array} elements
- * @param {function} callback
  */
-function onKeyPress(key, elements, callback) {
+function onKeyPress(key: string, elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
     if (!Array.isArray(elements)) {
         elements = [elements];
     }
 
-    const listener = event => {
+    const listener = (event: KeyboardEvent) => {
         if (event.key === key) {
             callback(event);
         }
@@ -96,19 +89,15 @@ function onKeyPress(key, elements, callback) {
 
 /**
  * Listen to enter press on the given element(s).
- * @param {HTMLElement|Array} elements
- * @param {function} callback
  */
-export function onEnterPress(elements, callback) {
+export function onEnterPress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
     onKeyPress('Enter', elements, callback);
 }
 
 /**
  * Listen to escape press on the given element(s).
- * @param {HTMLElement|Array} elements
- * @param {function} callback
  */
-export function onEscapePress(elements, callback) {
+export function onEscapePress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
     onKeyPress('Escape', elements, callback);
 }
 
@@ -116,14 +105,15 @@ export function onEscapePress(elements, callback) {
  * Set a listener on an element for an event emitted by a child
  * matching the given childSelector param.
  * Used in a similar fashion to jQuery's $('listener').on('eventName', 'childSelector', callback)
- * @param {Element} listenerElement
- * @param {String} childSelector
- * @param {String} eventName
- * @param {Function} callback
  */
-export function onChildEvent(listenerElement, childSelector, eventName, callback) {
-    listenerElement.addEventListener(eventName, event => {
-        const matchingChild = event.target.closest(childSelector);
+export function onChildEvent(
+    listenerElement: HTMLElement,
+    childSelector: string,
+    eventName: string,
+    callback: (this: HTMLElement, e: Event, child: HTMLElement) => any
+): void {
+    listenerElement.addEventListener(eventName, (event: Event) => {
+        const matchingChild = (event.target as HTMLElement|null)?.closest(childSelector) as HTMLElement;
         if (matchingChild) {
             callback.call(matchingChild, event, matchingChild);
         }
@@ -132,16 +122,13 @@ export function onChildEvent(listenerElement, childSelector, eventName, callback
 
 /**
  * Look for elements that match the given selector and contain the given text.
- * Is case insensitive and returns the first result or null if nothing is found.
- * @param {String} selector
- * @param {String} text
- * @returns {Element}
+ * Is case-insensitive and returns the first result or null if nothing is found.
  */
-export function findText(selector, text) {
+export function findText(selector: string, text: string): Element|null {
     const elements = document.querySelectorAll(selector);
     text = text.toLowerCase();
     for (const element of elements) {
-        if (element.textContent.toLowerCase().includes(text)) {
+        if ((element.textContent || '').toLowerCase().includes(text) && isHTMLElement(element)) {
             return element;
         }
     }
@@ -151,17 +138,15 @@ export function findText(selector, text) {
 /**
  * Show a loading indicator in the given element.
  * This will effectively clear the element.
- * @param {Element} element
  */
-export function showLoading(element) {
+export function showLoading(element: HTMLElement): void {
     element.innerHTML = '<div class="loading-container"><div></div><div></div><div></div></div>';
 }
 
 /**
  * Get a loading element indicator element.
- * @returns {Element}
  */
-export function getLoading() {
+export function getLoading(): HTMLElement {
     const wrap = document.createElement('div');
     wrap.classList.add('loading-container');
     wrap.innerHTML = '<div></div><div></div><div></div>';
@@ -170,9 +155,8 @@ export function getLoading() {
 
 /**
  * Remove any loading indicators within the given element.
- * @param {Element} element
  */
-export function removeLoading(element) {
+export function removeLoading(element: HTMLElement): void {
     const loadingEls = element.querySelectorAll('.loading-container');
     for (const el of loadingEls) {
         el.remove();
@@ -182,12 +166,15 @@ export function removeLoading(element) {
 /**
  * Convert the given html data into a live DOM element.
  * Initiates any components defined in the data.
- * @param {String} html
- * @returns {Element}
  */
-export function htmlToDom(html) {
+export function htmlToDom(html: string): HTMLElement {
     const wrap = document.createElement('div');
     wrap.innerHTML = html;
     window.$components.init(wrap);
-    return wrap.children[0];
+    const firstChild = wrap.children[0];
+    if (!isHTMLElement(firstChild)) {
+        throw new Error('Could not find child HTMLElement when creating DOM element from HTML');
+    }
+
+    return firstChild;
 }
similarity index 66%
rename from resources/js/services/keyboard-navigation.js
rename to resources/js/services/keyboard-navigation.ts
index 34111bb2d37886e3d98a7e150fd7ada800ba186b..13fbdfecc9d81561bec3ef7ce183e605f914fce7 100644 (file)
@@ -1,14 +1,17 @@
+import {isHTMLElement} from "./dom";
+
+type OptionalKeyEventHandler = ((e: KeyboardEvent) => any)|null;
+
 /**
  * Handle common keyboard navigation events within a given container.
  */
 export class KeyboardNavigationHandler {
 
-    /**
-     * @param {Element} container
-     * @param {Function|null} onEscape
-     * @param {Function|null} onEnter
-     */
-    constructor(container, onEscape = null, onEnter = null) {
+    protected containers: HTMLElement[];
+    protected onEscape: OptionalKeyEventHandler;
+    protected onEnter: OptionalKeyEventHandler;
+
+    constructor(container: HTMLElement, onEscape: OptionalKeyEventHandler = null, onEnter: OptionalKeyEventHandler = null) {
         this.containers = [container];
         this.onEscape = onEscape;
         this.onEnter = onEnter;
@@ -18,9 +21,8 @@ export class KeyboardNavigationHandler {
     /**
      * Also share the keyboard event handling to the given element.
      * Only elements within the original container are considered focusable though.
-     * @param {Element} element
      */
-    shareHandlingToEl(element) {
+    shareHandlingToEl(element: HTMLElement) {
         this.containers.push(element);
         element.addEventListener('keydown', this.#keydownHandler.bind(this));
     }
@@ -30,7 +32,8 @@ export class KeyboardNavigationHandler {
      */
     focusNext() {
         const focusable = this.#getFocusable();
-        const currentIndex = focusable.indexOf(document.activeElement);
+        const activeEl = document.activeElement;
+        const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1;
         let newIndex = currentIndex + 1;
         if (newIndex >= focusable.length) {
             newIndex = 0;
@@ -44,7 +47,8 @@ export class KeyboardNavigationHandler {
      */
     focusPrevious() {
         const focusable = this.#getFocusable();
-        const currentIndex = focusable.indexOf(document.activeElement);
+        const activeEl = document.activeElement;
+        const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1;
         let newIndex = currentIndex - 1;
         if (newIndex < 0) {
             newIndex = focusable.length - 1;
@@ -53,12 +57,9 @@ export class KeyboardNavigationHandler {
         focusable[newIndex].focus();
     }
 
-    /**
-     * @param {KeyboardEvent} event
-     */
-    #keydownHandler(event) {
+    #keydownHandler(event: KeyboardEvent) {
         // Ignore certain key events in inputs to allow text editing.
-        if (event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
+        if (isHTMLElement(event.target) && event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
             return;
         }
 
@@ -71,7 +72,7 @@ export class KeyboardNavigationHandler {
         } else if (event.key === 'Escape') {
             if (this.onEscape) {
                 this.onEscape(event);
-            } else if (document.activeElement) {
+            } else if (isHTMLElement(document.activeElement)) {
                 document.activeElement.blur();
             }
         } else if (event.key === 'Enter' && this.onEnter) {
@@ -81,14 +82,15 @@ export class KeyboardNavigationHandler {
 
     /**
      * Get an array of focusable elements within the current containers.
-     * @returns {Element[]}
      */
-    #getFocusable() {
-        const focusable = [];
+    #getFocusable(): HTMLElement[] {
+        const focusable: HTMLElement[] = [];
         const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])';
         for (const container of this.containers) {
-            focusable.push(...container.querySelectorAll(selector));
+            const toAdd = [...container.querySelectorAll(selector)].filter(e => isHTMLElement(e));
+            focusable.push(...toAdd);
         }
+
         return focusable;
     }
 
diff --git a/resources/js/services/util.js b/resources/js/services/util.js
deleted file mode 100644 (file)
index 1264d10..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * Returns a function, that, as long as it continues to be invoked, will not
- * be triggered. The function will be called after it stops being called for
- * N milliseconds. If `immediate` is passed, trigger the function on the
- * leading edge, instead of the trailing.
- * @attribution https://davidwalsh.name/javascript-debounce-function
- * @param {Function} func
- * @param {Number} waitMs
- * @param {Boolean} immediate
- * @returns {Function}
- */
-export function debounce(func, waitMs, immediate) {
-    let timeout;
-    return function debouncedWrapper(...args) {
-        const context = this;
-        const later = function debouncedTimeout() {
-            timeout = null;
-            if (!immediate) func.apply(context, args);
-        };
-        const callNow = immediate && !timeout;
-        clearTimeout(timeout);
-        timeout = setTimeout(later, waitMs);
-        if (callNow) func.apply(context, args);
-    };
-}
-
-/**
- * Scroll and highlight an element.
- * @param {HTMLElement} element
- */
-export function scrollAndHighlightElement(element) {
-    if (!element) return;
-
-    let parent = element;
-    while (parent.parentElement) {
-        parent = parent.parentElement;
-        if (parent.nodeName === 'DETAILS' && !parent.open) {
-            parent.open = true;
-        }
-    }
-
-    element.scrollIntoView({behavior: 'smooth'});
-
-    const highlight = getComputedStyle(document.body).getPropertyValue('--color-link');
-    element.style.outline = `2px dashed ${highlight}`;
-    element.style.outlineOffset = '5px';
-    element.style.transition = null;
-    setTimeout(() => {
-        element.style.transition = 'outline linear 3s';
-        element.style.outline = '2px dashed rgba(0, 0, 0, 0)';
-        const listener = () => {
-            element.removeEventListener('transitionend', listener);
-            element.style.transition = null;
-            element.style.outline = null;
-            element.style.outlineOffset = null;
-        };
-        element.addEventListener('transitionend', listener);
-    }, 1000);
-}
-
-/**
- * Escape any HTML in the given 'unsafe' string.
- * Take from https://stackoverflow.com/a/6234804.
- * @param {String} unsafe
- * @returns {string}
- */
-export function escapeHtml(unsafe) {
-    return unsafe
-        .replace(/&/g, '&amp;')
-        .replace(/</g, '&lt;')
-        .replace(/>/g, '&gt;')
-        .replace(/"/g, '&quot;')
-        .replace(/'/g, '&#039;');
-}
-
-/**
- * Generate a random unique ID.
- *
- * @returns {string}
- */
-export function uniqueId() {
-    // eslint-disable-next-line no-bitwise
-    const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
-    return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`);
-}
-
-/**
- * Generate a random smaller unique ID.
- *
- * @returns {string}
- */
-export function uniqueIdSmall() {
-    // eslint-disable-next-line no-bitwise
-    const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
-    return S4();
-}
-
-/**
- * Create a promise that resolves after the given time.
- * @param {int} timeMs
- * @returns {Promise}
- */
-export function wait(timeMs) {
-    return new Promise(res => {
-        setTimeout(res, timeMs);
-    });
-}
diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts
new file mode 100644 (file)
index 0000000..c5a5d2d
--- /dev/null
@@ -0,0 +1,147 @@
+/**
+ * Returns a function, that, as long as it continues to be invoked, will not
+ * be triggered. The function will be called after it stops being called for
+ * N milliseconds. If `immediate` is passed, trigger the function on the
+ * leading edge, instead of the trailing.
+ * @attribution https://davidwalsh.name/javascript-debounce-function
+ */
+export function debounce(func: Function, waitMs: number, immediate: boolean): Function {
+    let timeout: number|null = null;
+    return function debouncedWrapper(this: any, ...args: any[]) {
+        const context: any = this;
+        const later = function debouncedTimeout() {
+            timeout = null;
+            if (!immediate) func.apply(context, args);
+        };
+        const callNow = immediate && !timeout;
+        if (timeout) {
+            clearTimeout(timeout);
+        }
+        timeout = window.setTimeout(later, waitMs);
+        if (callNow) func.apply(context, args);
+    };
+}
+
+function isDetailsElement(element: HTMLElement): element is HTMLDetailsElement {
+    return element.nodeName === 'DETAILS';
+}
+
+/**
+ * Scroll-to and highlight an element.
+ */
+export function scrollAndHighlightElement(element: HTMLElement): void {
+    if (!element) return;
+
+    // Open up parent <details> elements if within
+    let parent = element;
+    while (parent.parentElement) {
+        parent = parent.parentElement;
+        if (isDetailsElement(parent) && !parent.open) {
+            parent.open = true;
+        }
+    }
+
+    element.scrollIntoView({behavior: 'smooth'});
+
+    const highlight = getComputedStyle(document.body).getPropertyValue('--color-link');
+    element.style.outline = `2px dashed ${highlight}`;
+    element.style.outlineOffset = '5px';
+    element.style.removeProperty('transition');
+    setTimeout(() => {
+        element.style.transition = 'outline linear 3s';
+        element.style.outline = '2px dashed rgba(0, 0, 0, 0)';
+        const listener = () => {
+            element.removeEventListener('transitionend', listener);
+            element.style.removeProperty('transition');
+            element.style.removeProperty('outline');
+            element.style.removeProperty('outlineOffset');
+        };
+        element.addEventListener('transitionend', listener);
+    }, 1000);
+}
+
+/**
+ * Escape any HTML in the given 'unsafe' string.
+ * Take from https://stackoverflow.com/a/6234804.
+ */
+export function escapeHtml(unsafe: string): string {
+    return unsafe
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;')
+        .replace(/'/g, '&#039;');
+}
+
+/**
+ * Generate a random unique ID.
+ */
+export function uniqueId(): string {
+    // eslint-disable-next-line no-bitwise
+    const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
+    return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`);
+}
+
+/**
+ * Generate a random smaller unique ID.
+ */
+export function uniqueIdSmall(): string {
+    // eslint-disable-next-line no-bitwise
+    const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
+    return S4();
+}
+
+/**
+ * Create a promise that resolves after the given time.
+ */
+export function wait(timeMs: number): Promise<any> {
+    return new Promise(res => {
+        setTimeout(res, timeMs);
+    });
+}
+
+/**
+ * Generate a full URL from the given relative URL, using a base
+ * URL defined in the head of the page.
+ */
+export function baseUrl(path: string): string {
+    let targetPath = path;
+    const baseUrlMeta = document.querySelector('meta[name="base-url"]');
+    if (!baseUrlMeta) {
+        throw new Error('Could not find expected base-url meta tag in document');
+    }
+
+    let basePath = baseUrlMeta.getAttribute('content') || '';
+    if (basePath[basePath.length - 1] === '/') {
+        basePath = basePath.slice(0, basePath.length - 1);
+    }
+
+    if (targetPath[0] === '/') {
+        targetPath = targetPath.slice(1);
+    }
+
+    return `${basePath}/${targetPath}`;
+}
+
+/**
+ * Get the current version of BookStack in use.
+ * Grabs this from the version query used on app assets.
+ */
+function getVersion(): string {
+    const styleLink = document.querySelector('link[href*="/dist/styles.css?version="]');
+    if (!styleLink) {
+        throw new Error('Could not find expected style link in document for version use');
+    }
+
+    const href = (styleLink.getAttribute('href') || '');
+    return href.split('?version=').pop() || '';
+}
+
+/**
+ * Perform a module import, Ensuring the import is fetched with the current
+ * app version as a cache-breaker.
+ */
+export function importVersioned(moduleName: string): Promise<object> {
+    const importPath = window.baseUrl(`dist/${moduleName}.js?version=${getVersion()}`);
+    return import(importPath);
+}
\ No newline at end of file
index 342cac0af74c4df3677482e5efc8ba4a92e60f90..197c50b0e448aec7c7d62f3a25f943ecf4112a1e 100644 (file)
@@ -1,5 +1,5 @@
 import * as DrawIO from '../services/drawio.ts';
-import {wait} from '../services/util';
+import {wait} from '../services/util.ts';
 
 let pageEditor = null;
 let currentNode = null;
index c79671d76467a4baf3b8edcab39598a769b996ce..5f14cb9dbc90a3230dfe632313b41192c5163463 100644 (file)
   border-radius: 4px;
 }
 
+.cm-editor .cm-line {
+  line-height: 1.6;
+}
+
 .cm-editor .cm-line, .cm-editor .cm-gutter {
   font-family: var(--font-code);
 }
index 67df4171499f841407d927e05e443346a0df762d..1c679aaa0ddef2462d6bbd705e2d6da3cf85e4fe 100644 (file)
@@ -545,6 +545,43 @@ input[type=color] {
   outline: 1px solid var(--color-primary);
 }
 
+.custom-simple-file-input {
+  max-width: 100%;
+  border: 1px solid;
+  @include lightDark(border-color, #DDD, #666);
+  border-radius: 4px;
+  padding: $-s $-m;
+}
+.custom-simple-file-input::file-selector-button {
+  background-color: transparent;
+  text-decoration: none;
+  font-size: 0.8rem;
+  line-height: 1.4em;
+  padding: $-xs $-s;
+  border: 1px solid;
+  font-weight: 400;
+  outline: 0;
+  border-radius: 4px;
+  cursor: pointer;
+  margin-right: $-m;
+  @include lightDark(color, #666, #AAA);
+  @include lightDark(border-color, #CCC, #666);
+  &:hover, &:focus, &:active {
+    @include lightDark(color, #444, #BBB);
+    border: 1px solid #CCC;
+    box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);
+    background-color: #F2F2F2;
+    @include lightDark(background-color, #f8f8f8, #444);
+    filter: none;
+  }
+  &:active {
+    border-color: #BBB;
+    background-color: #DDD;
+    color: #666;
+    box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1);
+  }
+}
+
 input.shortcut-input {
   width: auto;
   max-width: 120px;
index 636367e3aeb0f14cce35fa77e4f903455914335d..2106f86e62509a7e0cc82e2910130ab805227517 100644 (file)
@@ -106,6 +106,10 @@ $loadingSize: 10px;
   }
 }
 
+.inline.block .loading-container {
+  margin: $-xs $-s;
+}
+
 .skip-to-content-link {
   position: fixed;
   top: -52px;
@@ -138,6 +142,11 @@ $loadingSize: 10px;
     font-size: 16px;
     padding: $-s $-m;
   }
+  input[type="text"]:focus {
+    outline: 1px solid var(--color-primary);
+    border-radius: 3px 3px 0 0;
+    outline-offset: -1px;
+  }
   .entity-list {
     overflow-y: scroll;
     height: 400px;
@@ -171,6 +180,19 @@ $loadingSize: 10px;
       font-size: 14px;
     }
   }
+  &.small {
+    .entity-list-item {
+      padding: $-xs $-m;
+    }
+    .entity-list, .loading {
+      height: 300px;
+    }
+    input[type="text"] {
+      font-size: 13px;
+      padding: $-xs $-m;
+      height: auto;
+    }
+  }
 }
 
 .fullscreen {
@@ -230,4 +252,9 @@ $loadingSize: 10px;
       transform: rotate(180deg);
     }
   }
+}
+
+.import-item {
+  border-inline-start: 2px solid currentColor;
+  padding-inline-start: $-xs;
 }
\ No newline at end of file
index 0b407a8609abb98f3b6ae99d1bb4930b7ab8249f..418c0fea8d1d40ae16e4af61c45609e0180ebd28 100644 (file)
                 <span>@icon('tag')</span>
                 <span>{{ trans('entities.tags_view_tags') }}</span>
             </a>
+
+            @if(userCan('content-import'))
+                <a href="{{ url('/import') }}" class="icon-list-item">
+                    <span>@icon('upload')</span>
+                    <span>{{ trans('entities.import') }}</span>
+                </a>
+            @endif
         </div>
     </div>
 
index a55ab56d199cf174b6144f26bf0b7306bfaaa72d..e58c842ba421a324981c8849c989ab5d36b5fdb2 100644 (file)
@@ -18,6 +18,7 @@
         <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li>
         <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li>
         <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/zip') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_zip') }}</span><span>.zip</span></a></li>
     </ul>
 
 </div>
index c1280cfb2f7934b7c26ccb1d4060a08ea0198521..0cdf4376cc7ea38b07bfbd2ee31fb73590bfbbf8 100644 (file)
@@ -1,3 +1,11 @@
+{{--
+$name - string
+$autofocus - boolean, optional
+$entityTypes - string, optional
+$entityPermission - string, optional
+$selectorEndpoint - string, optional
+$selectorSize - string, optional (compact)
+--}}
 <div class="form-group entity-selector-container">
     <div component="entity-selector"
          refs="entity-selector-popup@selector"
diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php
new file mode 100644 (file)
index 0000000..a28b79b
--- /dev/null
@@ -0,0 +1,88 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small">
+
+        <main class="card content-wrap auto-height mt-xxl">
+            <h1 class="list-heading">{{ trans('entities.import_continue') }}</h1>
+            <p class="text-muted">{{ trans('entities.import_continue_desc') }}</p>
+
+            @if(session()->has('import_errors'))
+                <div class="mb-m">
+                    <label class="setting-list-label mb-xs text-neg">@icon('warning') {{ trans('entities.import_errors') }}</label>
+                    <p class="mb-xs small">{{ trans('entities.import_errors_desc') }}</p>
+                    @foreach(session()->get('import_errors') ?? [] as $error)
+                        <p class="mb-none text-neg">{{ $error }}</p>
+                    @endforeach
+                    <hr class="mt-m">
+                </div>
+            @endif
+
+            <div class="mb-m">
+                <label class="setting-list-label mb-m">{{ trans('entities.import_details') }}</label>
+                <div class="flex-container-row justify-space-between wrap">
+                    <div>
+                        @include('exports.parts.import-item', ['type' => $import->type, 'model' => $data])
+                    </div>
+                    <div class="text-right text-muted">
+                        <div>{{ trans('entities.import_size', ['size' => $import->getSizeString()]) }}</div>
+                        <div><span title="{{ $import->created_at->toISOString() }}">{{ trans('entities.import_uploaded_at', ['relativeTime' => $import->created_at->diffForHumans()]) }}</span></div>
+                        @if($import->createdBy)
+                            <div>
+                                {{ trans('entities.import_uploaded_by') }}
+                                <a href="{{ $import->createdBy->getProfileUrl() }}">{{ $import->createdBy->name }}</a>
+                            </div>
+                        @endif
+                    </div>
+                </div>
+            </div>
+
+            <form id="import-run-form"
+                  action="{{ $import->getUrl() }}"
+                  method="POST">
+                {{ csrf_field() }}
+
+                @if($import->type === 'page' || $import->type === 'chapter')
+                    <hr>
+                    <label class="setting-list-label">{{ trans('entities.import_location') }}</label>
+                    <p class="small mb-s">{{ trans('entities.import_location_desc') }}</p>
+                    @if($errors->has('parent'))
+                        <div class="mb-s">
+                            @include('form.errors', ['name' => 'parent'])
+                        </div>
+                    @endif
+                    @include('entities.selector', [
+                        'name' => 'parent',
+                        'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book',
+                        'entityPermission' => "{$import->type}-create",
+                        'selectorSize' => 'compact small',
+                    ])
+                @endif
+
+                <div class="flex-container-row items-center justify-flex-end">
+                    <a href="{{ url('/import') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <div component="dropdown" class="inline block mx-s">
+                        <button refs="dropdown@toggle"
+                                type="button"
+                                title="{{ trans('common.delete') }}"
+                                class="button outline">{{ trans('common.delete') }}</button>
+                        <div refs="dropdown@menu" class="dropdown-menu">
+                            <p class="text-neg bold small px-m mb-xs">{{ trans('entities.import_delete_confirm') }}</p>
+                            <p class="small px-m mb-xs">{{ trans('entities.import_delete_desc') }}</p>
+                            <button type="submit" form="import-delete-form" class="text-link small text-item">{{ trans('common.confirm') }}</button>
+                        </div>
+                    </div>
+                    <button component="loading-button" type="submit" class="button">{{ trans('entities.import_run') }}</button>
+                </div>
+            </form>
+        </main>
+    </div>
+
+    <form id="import-delete-form"
+          action="{{ $import->getUrl() }}"
+          method="post">
+        {{ method_field('DELETE') }}
+        {{ csrf_field() }}
+    </form>
+
+@stop
diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php
new file mode 100644 (file)
index 0000000..be9de4c
--- /dev/null
@@ -0,0 +1,56 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        <main class="card content-wrap auto-height mt-xxl">
+            <h1 class="list-heading">{{ trans('entities.import') }}</h1>
+            <form action="{{ url('/import') }}" enctype="multipart/form-data" method="POST">
+                {{ csrf_field() }}
+                <div class="flex-container-row justify-space-between wrap gap-x-xl gap-y-s">
+                    <p class="flex min-width-l text-muted mb-s">{{ trans('entities.import_desc') }}</p>
+                    <div class="flex-none min-width-l flex-container-row justify-flex-end">
+                        <div class="mb-m">
+                            <label for="file">{{ trans('entities.import_zip_select') }}</label>
+                            <input type="file"
+                                   accept=".zip,application/zip,application/x-zip-compressed"
+                                   name="file"
+                                   id="file"
+                                   class="custom-simple-file-input">
+                            @include('form.errors', ['name' => 'file'])
+                        </div>
+                    </div>
+                </div>
+
+                @if(count($zipErrors) > 0)
+                    <p class="mb-xs"><strong class="text-neg">{{ trans('entities.import_zip_validation_errors') }}</strong></p>
+                    <ul class="mb-m">
+                        @foreach($zipErrors as $key => $error)
+                            <li><strong class="text-neg">[{{ $key }}]</strong>: {{ $error }}</li>
+                        @endforeach
+                    </ul>
+                @endif
+
+                <div class="text-right">
+                    <a href="{{ url('/books') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button">{{ trans('entities.import_validate') }}</button>
+                </div>
+            </form>
+        </main>
+
+        <main class="card content-wrap auto-height mt-xxl">
+            <h2 class="list-heading">{{ trans('entities.import_pending') }}</h2>
+            @if(count($imports) === 0)
+                <p>{{ trans('entities.import_pending_none') }}</p>
+            @else
+                <div class="item-list my-m">
+                    @foreach($imports as $import)
+                        @include('exports.parts.import', ['import' => $import])
+                    @endforeach
+                </div>
+            @endif
+        </main>
+    </div>
+
+@stop
diff --git a/resources/views/exports/parts/import-item.blade.php b/resources/views/exports/parts/import-item.blade.php
new file mode 100644 (file)
index 0000000..5da4b21
--- /dev/null
@@ -0,0 +1,28 @@
+{{--
+$type - string
+$model - object
+--}}
+<div class="import-item text-{{ $type }} mb-xs">
+    <p class="mb-none">@icon($type){{ $model->name }}</p>
+    <div class="ml-s">
+        <div class="text-muted">
+            @if($model->attachments ?? [])
+                <span>@icon('attach'){{ count($model->attachments) }}</span>
+            @endif
+            @if($model->images ?? [])
+                <span>@icon('image'){{ count($model->images) }}</span>
+            @endif
+            @if($model->tags ?? [])
+                <span>@icon('tag'){{ count($model->tags) }}</span>
+            @endif
+        </div>
+        @if(method_exists($model, 'children'))
+            @foreach($model->children() as $child)
+                @include('exports.parts.import-item', [
+                    'type' => ($child instanceof \BookStack\Exports\ZipExports\Models\ZipExportPage) ? 'page' : 'chapter',
+                    'model' => $child
+                ])
+            @endforeach
+        @endif
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php
new file mode 100644 (file)
index 0000000..2f7659c
--- /dev/null
@@ -0,0 +1,10 @@
+<div class="item-list-row flex-container-row items-center justify-space-between wrap">
+    <div class="px-m py-s">
+        <a href="{{ $import->getUrl() }}"
+           class="text-{{ $import->type }}">@icon($import->type) {{ $import->name }}</a>
+    </div>
+    <div class="px-m py-s flex-container-row gap-m items-center">
+        <div class="bold opacity-80 text-muted">{{ $import->getSizeString() }}</div>
+        <div class="bold opacity-80 text-muted min-width-xs text-right" title="{{ $import->created_at->toISOString() }}">@icon('time'){{ $import->created_at->diffForHumans() }}</div>
+    </div>
+</div>
\ No newline at end of file
index 03cd4be88f08edbc3e61e1f988036709390e3de5..72d41ee56c74f887551ea857fe2b65d892a2d688 100644 (file)
@@ -1,3 +1,6 @@
+{{--
+$name - string
+--}}
 @if($errors->has($name))
     <div class="text-neg text-small">{{ $errors->first($name) }}</div>
 @endif
\ No newline at end of file
index 9fa76f2bfd7741ebd964e1cb1bff6282e4175500..a77b80e4c696f1388c9d07de5a6d73463b1d85be 100644 (file)
@@ -37,6 +37,7 @@
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])</div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'content-import', 'label' => trans('settings.role_import_content')])</div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'editor-change', 'label' => trans('settings.role_editor_change')])</div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'receive-notifications', 'label' => trans('settings.role_notifications')])</div>
             </div>
index c0919d3247ba12eae80dddc7199f9a3ca020e261..71036485597d20d41e8b500aeded1341496d983b 100644 (file)
@@ -9,6 +9,7 @@
 use BookStack\Activity\Controllers\AuditLogApiController;
 use BookStack\Api\ApiDocsController;
 use BookStack\Entities\Controllers as EntityControllers;
+use BookStack\Exports\Controllers as ExportControllers;
 use BookStack\Permissions\ContentPermissionApiController;
 use BookStack\Search\SearchApiController;
 use BookStack\Uploads\Controllers\AttachmentApiController;
@@ -31,21 +32,20 @@ Route::get('books/{id}', [EntityControllers\BookApiController::class, 'read']);
 Route::put('books/{id}', [EntityControllers\BookApiController::class, 'update']);
 Route::delete('books/{id}', [EntityControllers\BookApiController::class, 'delete']);
 
-Route::get('books/{id}/export/html', [EntityControllers\BookExportApiController::class, 'exportHtml']);
-Route::get('books/{id}/export/pdf', [EntityControllers\BookExportApiController::class, 'exportPdf']);
-Route::get('books/{id}/export/plaintext', [EntityControllers\BookExportApiController::class, 'exportPlainText']);
-Route::get('books/{id}/export/markdown', [EntityControllers\BookExportApiController::class, 'exportMarkdown']);
+Route::get('books/{id}/export/html', [ExportControllers\BookExportApiController::class, 'exportHtml']);
+Route::get('books/{id}/export/pdf', [ExportControllers\BookExportApiController::class, 'exportPdf']);
+Route::get('books/{id}/export/plaintext', [ExportControllers\BookExportApiController::class, 'exportPlainText']);
+Route::get('books/{id}/export/markdown', [ExportControllers\BookExportApiController::class, 'exportMarkdown']);
 
 Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']);
 Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']);
 Route::get('chapters/{id}', [EntityControllers\ChapterApiController::class, 'read']);
 Route::put('chapters/{id}', [EntityControllers\ChapterApiController::class, 'update']);
 Route::delete('chapters/{id}', [EntityControllers\ChapterApiController::class, 'delete']);
-
-Route::get('chapters/{id}/export/html', [EntityControllers\ChapterExportApiController::class, 'exportHtml']);
-Route::get('chapters/{id}/export/pdf', [EntityControllers\ChapterExportApiController::class, 'exportPdf']);
-Route::get('chapters/{id}/export/plaintext', [EntityControllers\ChapterExportApiController::class, 'exportPlainText']);
-Route::get('chapters/{id}/export/markdown', [EntityControllers\ChapterExportApiController::class, 'exportMarkdown']);
+Route::get('chapters/{id}/export/html', [ExportControllers\ChapterExportApiController::class, 'exportHtml']);
+Route::get('chapters/{id}/export/pdf', [ExportControllers\ChapterExportApiController::class, 'exportPdf']);
+Route::get('chapters/{id}/export/plaintext', [ExportControllers\ChapterExportApiController::class, 'exportPlainText']);
+Route::get('chapters/{id}/export/markdown', [ExportControllers\ChapterExportApiController::class, 'exportMarkdown']);
 
 Route::get('pages', [EntityControllers\PageApiController::class, 'list']);
 Route::post('pages', [EntityControllers\PageApiController::class, 'create']);
@@ -53,10 +53,10 @@ Route::get('pages/{id}', [EntityControllers\PageApiController::class, 'read']);
 Route::put('pages/{id}', [EntityControllers\PageApiController::class, 'update']);
 Route::delete('pages/{id}', [EntityControllers\PageApiController::class, 'delete']);
 
-Route::get('pages/{id}/export/html', [EntityControllers\PageExportApiController::class, 'exportHtml']);
-Route::get('pages/{id}/export/pdf', [EntityControllers\PageExportApiController::class, 'exportPdf']);
-Route::get('pages/{id}/export/plaintext', [EntityControllers\PageExportApiController::class, 'exportPlainText']);
-Route::get('pages/{id}/export/markdown', [EntityControllers\PageExportApiController::class, 'exportMarkdown']);
+Route::get('pages/{id}/export/html', [ExportControllers\PageExportApiController::class, 'exportHtml']);
+Route::get('pages/{id}/export/pdf', [ExportControllers\PageExportApiController::class, 'exportPdf']);
+Route::get('pages/{id}/export/plaintext', [ExportControllers\PageExportApiController::class, 'exportPlainText']);
+Route::get('pages/{id}/export/markdown', [ExportControllers\PageExportApiController::class, 'exportMarkdown']);
 
 Route::get('image-gallery', [ImageGalleryApiController::class, 'list']);
 Route::post('image-gallery', [ImageGalleryApiController::class, 'create']);
index 81b938f32eccbde217f15ded0f9e572935c122bf..85f83352859a8d49b155f93202f786f155e01c47 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Api\UserApiTokenController;
 use BookStack\App\HomeController;
 use BookStack\App\MetaController;
 use BookStack\Entities\Controllers as EntityControllers;
+use BookStack\Exports\Controllers as ExportControllers;
 use BookStack\Http\Middleware\VerifyCsrfToken;
 use BookStack\Permissions\PermissionsController;
 use BookStack\References\ReferenceController;
@@ -74,11 +75,11 @@ Route::middleware('auth')->group(function () {
     Route::get('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'show']);
     Route::put('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'update']);
     Route::get('/books/{slug}/references', [ReferenceController::class, 'book']);
-    Route::get('/books/{bookSlug}/export/html', [EntityControllers\BookExportController::class, 'html']);
-    Route::get('/books/{bookSlug}/export/pdf', [EntityControllers\BookExportController::class, 'pdf']);
-    Route::get('/books/{bookSlug}/export/markdown', [EntityControllers\BookExportController::class, 'markdown']);
-    Route::get('/books/{bookSlug}/export/zip', [EntityControllers\BookExportController::class, 'zip']);
-    Route::get('/books/{bookSlug}/export/plaintext', [EntityControllers\BookExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']);
+    Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']);
+    Route::get('/books/{bookSlug}/export/markdown', [ExportControllers\BookExportController::class, 'markdown']);
+    Route::get('/books/{bookSlug}/export/zip', [ExportControllers\BookExportController::class, 'zip']);
+    Route::get('/books/{bookSlug}/export/plaintext', [ExportControllers\BookExportController::class, 'plainText']);
 
     // Pages
     Route::get('/books/{bookSlug}/create-page', [EntityControllers\PageController::class, 'create']);
@@ -86,10 +87,11 @@ Route::middleware('auth')->group(function () {
     Route::get('/books/{bookSlug}/draft/{pageId}', [EntityControllers\PageController::class, 'editDraft']);
     Route::post('/books/{bookSlug}/draft/{pageId}', [EntityControllers\PageController::class, 'store']);
     Route::get('/books/{bookSlug}/page/{pageSlug}', [EntityControllers\PageController::class, 'show']);
-    Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [EntityControllers\PageExportController::class, 'pdf']);
-    Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [EntityControllers\PageExportController::class, 'html']);
-    Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [EntityControllers\PageExportController::class, 'markdown']);
-    Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [EntityControllers\PageExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [ExportControllers\PageExportController::class, 'pdf']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [ExportControllers\PageExportController::class, 'html']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [ExportControllers\PageExportController::class, 'markdown']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [ExportControllers\PageExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/zip', [ExportControllers\PageExportController::class, 'zip']);
     Route::get('/books/{bookSlug}/page/{pageSlug}/edit', [EntityControllers\PageController::class, 'edit']);
     Route::get('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'showMove']);
     Route::put('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'move']);
@@ -126,10 +128,11 @@ Route::middleware('auth')->group(function () {
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [EntityControllers\ChapterController::class, 'edit']);
     Route::post('/books/{bookSlug}/chapter/{chapterSlug}/convert-to-book', [EntityControllers\ChapterController::class, 'convertToBook']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'showForChapter']);
-    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [EntityControllers\ChapterExportController::class, 'pdf']);
-    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [EntityControllers\ChapterExportController::class, 'html']);
-    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [EntityControllers\ChapterExportController::class, 'markdown']);
-    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [EntityControllers\ChapterExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ExportControllers\ChapterExportController::class, 'pdf']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ExportControllers\ChapterExportController::class, 'html']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ExportControllers\ChapterExportController::class, 'markdown']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ExportControllers\ChapterExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/zip', [ExportControllers\ChapterExportController::class, 'zip']);
     Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [EntityControllers\ChapterController::class, 'showDelete']);
@@ -203,6 +206,13 @@ Route::middleware('auth')->group(function () {
     // Watching
     Route::put('/watching/update', [ActivityControllers\WatchController::class, 'update']);
 
+    // Importing
+    Route::get('/import', [ExportControllers\ImportController::class, 'start']);
+    Route::post('/import', [ExportControllers\ImportController::class, 'upload']);
+    Route::get('/import/{id}', [ExportControllers\ImportController::class, 'show']);
+    Route::post('/import/{id}', [ExportControllers\ImportController::class, 'run']);
+    Route::delete('/import/{id}', [ExportControllers\ImportController::class, 'delete']);
+
     // Other Pages
     Route::get('/', [HomeController::class, 'index']);
     Route::get('/home', [HomeController::class, 'index']);
index 201f67b533338057e13b405d611d21cd2c1b1ba6..205f75a4d62c159c2fefde68712567981c73c693 100644 (file)
@@ -787,6 +787,20 @@ class OidcTest extends TestCase
         $this->assertTrue($user->hasRole($roleA->id));
     }
 
+    public function test_userinfo_endpoint_response_with_complex_json_content_type_handled()
+    {
+        $userinfoResponseData = [
+            'sub' => OidcJwtHelper::defaultPayload()['sub'],
+            'name' => 'Barry',
+        ];
+        $userinfoResponse = new Response(200, ['Content-Type'  => 'Application/Json ; charset=utf-8'], json_encode($userinfoResponseData));
+        $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
+        $resp->assertRedirect('/');
+
+        $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first();
+        $this->assertEquals('Barry', $user->name);
+    }
+
     public function test_userinfo_endpoint_jwks_response_handled()
     {
         $userinfoResponseData = OidcJwtHelper::idToken(['name' => 'Barry Jwks']);
index 62c39c2741f7321d45eed4667725a07b8019f123..d336e05a2403b0317f533c7e806f566850a8ea74 100644 (file)
@@ -87,6 +87,27 @@ class UpdateUrlCommandTest extends TestCase
         $this->assertEquals('a https://cats.example.com/donkey/cat url', $settingVal[0]['name']);
     }
 
+    public function test_command_updates_page_revisions()
+    {
+        $page = $this->entities->page();
+
+        for ($i = 0; $i < 2; $i++) {
+            $this->entities->updatePage($page, [
+                'name' => $page->name,
+                'markdown' => "[A link {$i}](https://example.com/donkey/cat)"
+            ]);
+        }
+
+        $this->runUpdate('https://example.com', 'https://cats.example.com');
+        setting()->flushCache();
+
+        $this->assertDatabaseHas('page_revisions', [
+            'page_id' => $page->id,
+            'markdown' => '[A link 1](https://cats.example.com/donkey/cat)',
+            'html' => '<p id="bkmrk-a-link-1"><a href="https://cats.example.com/donkey/cat">A link 1</a></p>' . "\n"
+        ]);
+    }
+
     protected function runUpdate(string $oldUrl, string $newUrl)
     {
         $this->artisan("bookstack:update-url {$oldUrl} {$newUrl}")
index 57b7c3f6b88c1b7ca8ee4cb237395701cd7a0c92..cabf23bd3d33fa7a4cd531dd61adbe396334f2c8 100644 (file)
@@ -393,11 +393,11 @@ class EntitySearchTest extends TestCase
         $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextBook->id}&entity_type=book");
         $this->withHtml($search)->assertElementNotContains('a:first-child', 'Zebras');
 
-        $searchBook->name = 'AAAAAAArdvarks';
+        $searchBook->name = '1AAAAAAArdvarks';
         $searchBook->save();
 
         $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextBook->id}&entity_type=book");
-        $this->withHtml($search)->assertElementContains('a:first-child', 'AAAAAAArdvarks');
+        $this->withHtml($search)->assertElementContains('a:first-child', '1AAAAAAArdvarks');
     }
 
     public function test_sibling_search_for_shelves_provides_results_in_alphabetical_order()
@@ -411,11 +411,11 @@ class EntitySearchTest extends TestCase
         $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextShelf->id}&entity_type=bookshelf");
         $this->withHtml($search)->assertElementNotContains('a:first-child', 'Zebras');
 
-        $searchShelf->name = 'AAAAAAArdvarks';
+        $searchShelf->name = '1AAAAAAArdvarks';
         $searchShelf->save();
 
         $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextShelf->id}&entity_type=bookshelf");
-        $this->withHtml($search)->assertElementContains('a:first-child', 'AAAAAAArdvarks');
+        $this->withHtml($search)->assertElementContains('a:first-child', '1AAAAAAArdvarks');
     }
 
     public function test_search_works_on_updated_page_content()
diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php
deleted file mode 100644 (file)
index 7aafa3b..0000000
+++ /dev/null
@@ -1,569 +0,0 @@
-<?php
-
-namespace Tests\Entity;
-
-use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\Chapter;
-use BookStack\Entities\Models\Page;
-use BookStack\Entities\Tools\PdfGenerator;
-use BookStack\Exceptions\PdfExportException;
-use Illuminate\Support\Facades\Storage;
-use Tests\TestCase;
-
-class ExportTest extends TestCase
-{
-    public function test_page_text_export()
-    {
-        $page = $this->entities->page();
-        $this->asEditor();
-
-        $resp = $this->get($page->getUrl('/export/plaintext'));
-        $resp->assertStatus(200);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
-    }
-
-    public function test_page_pdf_export()
-    {
-        $page = $this->entities->page();
-        $this->asEditor();
-
-        $resp = $this->get($page->getUrl('/export/pdf'));
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
-    }
-
-    public function test_page_html_export()
-    {
-        $page = $this->entities->page();
-        $this->asEditor();
-
-        $resp = $this->get($page->getUrl('/export/html'));
-        $resp->assertStatus(200);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
-    }
-
-    public function test_book_text_export()
-    {
-        $book = $this->entities->bookHasChaptersAndPages();
-        $directPage = $book->directPages()->first();
-        $chapter = $book->chapters()->first();
-        $chapterPage = $chapter->pages()->first();
-        $this->entities->updatePage($directPage, ['html' => '<p>My awesome page</p>']);
-        $this->entities->updatePage($chapterPage, ['html' => '<p>My little nested page</p>']);
-        $this->asEditor();
-
-        $resp = $this->get($book->getUrl('/export/plaintext'));
-        $resp->assertStatus(200);
-        $resp->assertSee($book->name);
-        $resp->assertSee($chapterPage->name);
-        $resp->assertSee($chapter->name);
-        $resp->assertSee($directPage->name);
-        $resp->assertSee('My awesome page');
-        $resp->assertSee('My little nested page');
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
-    }
-
-    public function test_book_text_export_format()
-    {
-        $entities = $this->entities->createChainBelongingToUser($this->users->viewer());
-        $this->entities->updatePage($entities['page'], ['html' => '<p>My great page</p><p>Full of <strong>great</strong> stuff</p>', 'name' => 'My wonderful page!']);
-        $entities['chapter']->name = 'Export chapter';
-        $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within";
-        $entities['book']->name = 'Export Book';
-        $entities['book']->description = "This is a book with stuff to export";
-        $entities['chapter']->save();
-        $entities['book']->save();
-
-        $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";
-        $resp->assertSee($expected);
-    }
-
-    public function test_book_pdf_export()
-    {
-        $page = $this->entities->page();
-        $book = $page->book;
-        $this->asEditor();
-
-        $resp = $this->get($book->getUrl('/export/pdf'));
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
-    }
-
-    public function test_book_html_export()
-    {
-        $page = $this->entities->page();
-        $book = $page->book;
-        $this->asEditor();
-
-        $resp = $this->get($book->getUrl('/export/html'));
-        $resp->assertStatus(200);
-        $resp->assertSee($book->name);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
-    }
-
-    public function test_book_html_export_shows_html_descriptions()
-    {
-        $book = $this->entities->bookHasChaptersAndPages();
-        $chapter = $book->chapters()->first();
-        $book->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
-        $chapter->description_html = '<p>A chapter description with <strong>HTML</strong> within!</p>';
-        $book->save();
-        $chapter->save();
-
-        $resp = $this->asEditor()->get($book->getUrl('/export/html'));
-        $resp->assertSee($book->description_html, false);
-        $resp->assertSee($chapter->description_html, false);
-    }
-
-    public function test_chapter_text_export()
-    {
-        $chapter = $this->entities->chapter();
-        $page = $chapter->pages[0];
-        $this->entities->updatePage($page, ['html' => '<p>This is content within the page!</p>']);
-        $this->asEditor();
-
-        $resp = $this->get($chapter->getUrl('/export/plaintext'));
-        $resp->assertStatus(200);
-        $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"');
-    }
-
-    public function test_chapter_text_export_format()
-    {
-        $entities = $this->entities->createChainBelongingToUser($this->users->viewer());
-        $this->entities->updatePage($entities['page'], ['html' => '<p>My great page</p><p>Full of <strong>great</strong> stuff</p>', 'name' => 'My wonderful page!']);
-        $entities['chapter']->name = 'Export chapter';
-        $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within";
-        $entities['chapter']->save();
-
-        $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";
-        $resp->assertSee($expected);
-    }
-
-    public function test_chapter_pdf_export()
-    {
-        $chapter = $this->entities->chapter();
-        $this->asEditor();
-
-        $resp = $this->get($chapter->getUrl('/export/pdf'));
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
-    }
-
-    public function test_chapter_html_export()
-    {
-        $chapter = $this->entities->chapter();
-        $page = $chapter->pages[0];
-        $this->asEditor();
-
-        $resp = $this->get($chapter->getUrl('/export/html'));
-        $resp->assertStatus(200);
-        $resp->assertSee($chapter->name);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
-    }
-
-    public function test_chapter_html_export_shows_html_descriptions()
-    {
-        $chapter = $this->entities->chapter();
-        $chapter->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
-        $chapter->save();
-
-        $resp = $this->asEditor()->get($chapter->getUrl('/export/html'));
-        $resp->assertSee($chapter->description_html, false);
-    }
-
-    public function test_page_html_export_contains_custom_head_if_set()
-    {
-        $page = $this->entities->page();
-
-        $customHeadContent = '<style>p{color: red;}</style>';
-        $this->setSettings(['app-custom-head' => $customHeadContent]);
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertSee($customHeadContent, false);
-    }
-
-    public function test_page_html_export_does_not_break_with_only_comments_in_custom_head()
-    {
-        $page = $this->entities->page();
-
-        $customHeadContent = '<!-- A comment -->';
-        $this->setSettings(['app-custom-head' => $customHeadContent]);
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertStatus(200);
-        $resp->assertSee($customHeadContent, false);
-    }
-
-    public function test_page_html_export_use_absolute_dates()
-    {
-        $page = $this->entities->page();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss'));
-        $resp->assertDontSee($page->created_at->diffForHumans());
-        $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss'));
-        $resp->assertDontSee($page->updated_at->diffForHumans());
-    }
-
-    public function test_page_export_does_not_include_user_or_revision_links()
-    {
-        $page = $this->entities->page();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertDontSee($page->getUrl('/revisions'));
-        $resp->assertDontSee($page->createdBy->getProfileUrl());
-        $resp->assertSee($page->createdBy->name);
-    }
-
-    public function test_page_export_sets_right_data_type_for_svg_embeds()
-    {
-        $page = $this->entities->page();
-        Storage::disk('local')->makeDirectory('uploads/images/gallery');
-        Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
-        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg">';
-        $page->save();
-
-        $this->asEditor();
-        $resp = $this->get($page->getUrl('/export/html'));
-        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
-
-        $resp->assertStatus(200);
-        $resp->assertSee('<img src="data:image/svg+xml;base64', false);
-    }
-
-    public function test_page_image_containment_works_on_multiple_images_within_a_single_line()
-    {
-        $page = $this->entities->page();
-        Storage::disk('local')->makeDirectory('uploads/images/gallery');
-        Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
-        Storage::disk('local')->put('uploads/images/gallery/svg_test2.svg', '<svg></svg>');
-        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg" class="a"><img src="http://localhost/uploads/images/gallery/svg_test2.svg" class="b">';
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
-        Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg');
-
-        $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test');
-    }
-
-    public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder()
-    {
-        $page = $this->entities->page();
-        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg"/>'
-            . '<img src="http://localhost/uploads/svg_test.svg"/>'
-            . '<img src="/uploads/svg_test.svg"/>';
-        $storageDisk = Storage::disk('local');
-        $storageDisk->makeDirectory('uploads/images/gallery');
-        $storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
-        $storageDisk->put('uploads/svg_test.svg', '<svg>bad</svg>');
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-
-        $storageDisk->delete('uploads/images/gallery/svg_test.svg');
-        $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);
-    }
-
-    public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local()
-    {
-        $contents = file_get_contents(public_path('.htaccess'));
-        config()->set('filesystems.images', 'local');
-
-        $page = $this->entities->page();
-        $page->html = '<img src="http://localhost/uploads/images/../../.htaccess"/>';
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertDontSee(base64_encode($contents));
-    }
-
-    public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure()
-    {
-        $testFilePath = storage_path('logs/test.txt');
-        config()->set('filesystems.images', 'local_secure');
-        file_put_contents($testFilePath, 'I am a cat');
-
-        $page = $this->entities->page();
-        $page->html = '<img src="http://localhost/uploads/images/../../logs/test.txt"/>';
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertDontSee(base64_encode('I am a cat'));
-        unlink($testFilePath);
-    }
-
-    public function test_exports_removes_scripts_from_custom_head()
-    {
-        $entities = [
-            Page::query()->first(), Chapter::query()->first(), Book::query()->first(),
-        ];
-        setting()->put('app-custom-head', '<script>window.donkey = "cat";</script><style>.my-test-class { color: red; }</style>');
-
-        foreach ($entities as $entity) {
-            $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
-            $resp->assertDontSee('window.donkey');
-            $resp->assertDontSee('<script', false);
-            $resp->assertSee('.my-test-class { color: red; }');
-        }
-    }
-
-    public function test_page_export_with_deleted_creator_and_updater()
-    {
-        $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']);
-        $page = $this->entities->page();
-        $page->created_by = $user->id;
-        $page->updated_by = $user->id;
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertSee('ExportWizardTheFifth');
-
-        $user->delete();
-        $resp = $this->get($page->getUrl('/export/html'));
-        $resp->assertStatus(200);
-        $resp->assertDontSee('ExportWizardTheFifth');
-    }
-
-    public function test_page_pdf_export_converts_iframes_to_links()
-    {
-        $page = Page::query()->first()->forceFill([
-            'html'     => '<iframe width="560" height="315" src="//www.youtube.com/embed/ShqUjt33uOs"></iframe>',
-        ]);
-        $page->save();
-
-        $pdfHtml = '';
-        $mockPdfGenerator = $this->mock(PdfGenerator::class);
-        $mockPdfGenerator->shouldReceive('fromHtml')
-            ->with(\Mockery::capture($pdfHtml))
-            ->andReturn('');
-        $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
-
-        $this->asEditor()->get($page->getUrl('/export/pdf'));
-        $this->assertStringNotContainsString('iframe>', $pdfHtml);
-        $this->assertStringContainsString('<p><a href="https://www.youtube.com/embed/ShqUjt33uOs">https://www.youtube.com/embed/ShqUjt33uOs</a></p>', $pdfHtml);
-    }
-
-    public function test_page_pdf_export_opens_details_blocks()
-    {
-        $page = $this->entities->page()->forceFill([
-            'html'     => '<details><summary>Hello</summary><p>Content!</p></details>',
-        ]);
-        $page->save();
-
-        $pdfHtml = '';
-        $mockPdfGenerator = $this->mock(PdfGenerator::class);
-        $mockPdfGenerator->shouldReceive('fromHtml')
-            ->with(\Mockery::capture($pdfHtml))
-            ->andReturn('');
-        $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
-
-        $this->asEditor()->get($page->getUrl('/export/pdf'));
-        $this->assertStringContainsString('<details open="open"', $pdfHtml);
-    }
-
-    public function test_page_markdown_export()
-    {
-        $page = $this->entities->page();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
-        $resp->assertStatus(200);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
-    }
-
-    public function test_page_markdown_export_uses_existing_markdown_if_apparent()
-    {
-        $page = $this->entities->page()->forceFill([
-            'markdown' => '# A header',
-            'html'     => '<h1>Dogcat</h1>',
-        ]);
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
-        $resp->assertSee('A header');
-        $resp->assertDontSee('Dogcat');
-    }
-
-    public function test_page_markdown_export_converts_html_where_no_markdown()
-    {
-        $page = $this->entities->page()->forceFill([
-            'markdown' => '',
-            'html'     => '<h1>Dogcat</h1><p>Some <strong>bold</strong> text</p>',
-        ]);
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
-        $resp->assertSee("# Dogcat\n\nSome **bold** text");
-    }
-
-    public function test_chapter_markdown_export()
-    {
-        $chapter = $this->entities->chapter();
-        $page = $chapter->pages()->first();
-        $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown'));
-
-        $resp->assertSee('# ' . $chapter->name);
-        $resp->assertSee('# ' . $page->name);
-    }
-
-    public function test_book_markdown_export()
-    {
-        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
-        $chapter = $book->chapters()->first();
-        $page = $chapter->pages()->first();
-        $resp = $this->asEditor()->get($book->getUrl('/export/markdown'));
-
-        $resp->assertSee('# ' . $book->name);
-        $resp->assertSee('# ' . $chapter->name);
-        $resp->assertSee('# ' . $page->name);
-    }
-
-    public function test_book_markdown_export_concats_immediate_pages_with_newlines()
-    {
-        /** @var Book $book */
-        $book = Book::query()->whereHas('pages')->first();
-
-        $this->asEditor()->get($book->getUrl('/create-page'));
-        $this->get($book->getUrl('/create-page'));
-
-        [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get();
-        $pageA->html = '<p>hello tester</p>';
-        $pageA->save();
-        $pageB->name = 'The second page in this test';
-        $pageB->save();
-
-        $resp = $this->get($book->getUrl('/export/markdown'));
-        $resp->assertDontSee('hello tester# The second page in this test');
-        $resp->assertSee("hello tester\n\n# The second page in this test");
-    }
-
-    public function test_export_option_only_visible_and_accessible_with_permission()
-    {
-        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
-        $chapter = $book->chapters()->first();
-        $page = $chapter->pages()->first();
-        $entities = [$book, $chapter, $page];
-        $user = $this->users->viewer();
-        $this->actingAs($user);
-
-        foreach ($entities as $entity) {
-            $resp = $this->get($entity->getUrl());
-            $resp->assertSee('/export/pdf');
-        }
-
-        $this->permissions->removeUserRolePermissions($user, ['content-export']);
-
-        foreach ($entities as $entity) {
-            $resp = $this->get($entity->getUrl());
-            $resp->assertDontSee('/export/pdf');
-            $resp = $this->get($entity->getUrl('/export/pdf'));
-            $this->assertPermissionError($resp);
-        }
-    }
-
-    public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
-    {
-        $page = $this->entities->page();
-
-        config()->set('exports.snappy.pdf_binary', '/abc123');
-        config()->set('app.allow_untrusted_server_fetching', false);
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
-        $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage.
-
-        config()->set('app.allow_untrusted_server_fetching', true);
-        $resp = $this->get($page->getUrl('/export/pdf'));
-        $resp->assertStatus(500); // Bad response indicates wkhtml usage
-    }
-
-    public function test_pdf_command_option_used_if_set()
-    {
-        $page = $this->entities->page();
-        $command = 'cp {input_html_path} {output_pdf_path}';
-        config()->set('exports.pdf_command', $command);
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
-        $download = $resp->getContent();
-
-        $this->assertStringContainsString(e($page->name), $download);
-        $this->assertStringContainsString('<html lang=', $download);
-    }
-
-    public function test_pdf_command_option_errors_if_output_path_not_written_to()
-    {
-        $page = $this->entities->page();
-        $command = 'echo "hi"';
-        config()->set('exports.pdf_command', $command);
-
-        $this->assertThrows(function () use ($page) {
-            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
-        }, PdfExportException::class);
-    }
-
-    public function test_pdf_command_option_errors_if_command_returns_error_status()
-    {
-        $page = $this->entities->page();
-        $command = 'exit 1';
-        config()->set('exports.pdf_command', $command);
-
-        $this->assertThrows(function () use ($page) {
-            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
-        }, PdfExportException::class);
-    }
-
-    public function test_pdf_command_timout_option_limits_export_time()
-    {
-        $page = $this->entities->page();
-        $command = 'php -r \'sleep(4);\'';
-        config()->set('exports.pdf_command', $command);
-        config()->set('exports.pdf_command_timeout', 1);
-
-        $this->assertThrows(function () use ($page) {
-            $start = time();
-            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
-
-            $this->assertTrue(time() < ($start + 3));
-        }, PdfExportException::class,
-            "PDF Export via command failed due to timeout at 1 second(s)");
-    }
-
-    public function test_html_exports_contain_csp_meta_tag()
-    {
-        $entities = [
-            $this->entities->page(),
-            $this->entities->book(),
-            $this->entities->chapter(),
-        ];
-
-        foreach ($entities as $entity) {
-            $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
-            $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]');
-        }
-    }
-
-    public function test_html_exports_contain_body_classes_for_export_identification()
-    {
-        $page = $this->entities->page();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none');
-    }
-}
diff --git a/tests/Exports/ExportUiTest.php b/tests/Exports/ExportUiTest.php
new file mode 100644 (file)
index 0000000..77b26ad
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Book;
+use Tests\TestCase;
+
+class ExportUiTest extends TestCase
+{
+    public function test_export_option_only_visible_and_accessible_with_permission()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
+        $chapter = $book->chapters()->first();
+        $page = $chapter->pages()->first();
+        $entities = [$book, $chapter, $page];
+        $user = $this->users->viewer();
+        $this->actingAs($user);
+
+        foreach ($entities as $entity) {
+            $resp = $this->get($entity->getUrl());
+            $resp->assertSee('/export/pdf');
+        }
+
+        $this->permissions->removeUserRolePermissions($user, ['content-export']);
+
+        foreach ($entities as $entity) {
+            $resp = $this->get($entity->getUrl());
+            $resp->assertDontSee('/export/pdf');
+            $resp = $this->get($entity->getUrl('/export/pdf'));
+            $this->assertPermissionError($resp);
+        }
+    }
+}
diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php
new file mode 100644 (file)
index 0000000..069cf28
--- /dev/null
@@ -0,0 +1,253 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class HtmlExportTest extends TestCase
+{
+    public function test_page_html_export()
+    {
+        $page = $this->entities->page();
+        $this->asEditor();
+
+        $resp = $this->get($page->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
+    }
+
+    public function test_book_html_export()
+    {
+        $page = $this->entities->page();
+        $book = $page->book;
+        $this->asEditor();
+
+        $resp = $this->get($book->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertSee($book->name);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
+    }
+
+    public function test_book_html_export_shows_html_descriptions()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $chapter = $book->chapters()->first();
+        $book->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
+        $chapter->description_html = '<p>A chapter description with <strong>HTML</strong> within!</p>';
+        $book->save();
+        $chapter->save();
+
+        $resp = $this->asEditor()->get($book->getUrl('/export/html'));
+        $resp->assertSee($book->description_html, false);
+        $resp->assertSee($chapter->description_html, false);
+    }
+
+    public function test_chapter_html_export()
+    {
+        $chapter = $this->entities->chapter();
+        $page = $chapter->pages[0];
+        $this->asEditor();
+
+        $resp = $this->get($chapter->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertSee($chapter->name);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
+    }
+
+    public function test_chapter_html_export_shows_html_descriptions()
+    {
+        $chapter = $this->entities->chapter();
+        $chapter->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
+        $chapter->save();
+
+        $resp = $this->asEditor()->get($chapter->getUrl('/export/html'));
+        $resp->assertSee($chapter->description_html, false);
+    }
+
+    public function test_page_html_export_contains_custom_head_if_set()
+    {
+        $page = $this->entities->page();
+
+        $customHeadContent = '<style>p{color: red;}</style>';
+        $this->setSettings(['app-custom-head' => $customHeadContent]);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertSee($customHeadContent, false);
+    }
+
+    public function test_page_html_export_does_not_break_with_only_comments_in_custom_head()
+    {
+        $page = $this->entities->page();
+
+        $customHeadContent = '<!-- A comment -->';
+        $this->setSettings(['app-custom-head' => $customHeadContent]);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertSee($customHeadContent, false);
+    }
+
+    public function test_page_html_export_use_absolute_dates()
+    {
+        $page = $this->entities->page();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss'));
+        $resp->assertDontSee($page->created_at->diffForHumans());
+        $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss'));
+        $resp->assertDontSee($page->updated_at->diffForHumans());
+    }
+
+    public function test_page_export_does_not_include_user_or_revision_links()
+    {
+        $page = $this->entities->page();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertDontSee($page->getUrl('/revisions'));
+        $resp->assertDontSee($page->createdBy->getProfileUrl());
+        $resp->assertSee($page->createdBy->name);
+    }
+
+    public function test_page_export_sets_right_data_type_for_svg_embeds()
+    {
+        $page = $this->entities->page();
+        Storage::disk('local')->makeDirectory('uploads/images/gallery');
+        Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
+        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg">';
+        $page->save();
+
+        $this->asEditor();
+        $resp = $this->get($page->getUrl('/export/html'));
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
+
+        $resp->assertStatus(200);
+        $resp->assertSee('<img src="data:image/svg+xml;base64', false);
+    }
+
+    public function test_page_image_containment_works_on_multiple_images_within_a_single_line()
+    {
+        $page = $this->entities->page();
+        Storage::disk('local')->makeDirectory('uploads/images/gallery');
+        Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
+        Storage::disk('local')->put('uploads/images/gallery/svg_test2.svg', '<svg></svg>');
+        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg" class="a"><img src="http://localhost/uploads/images/gallery/svg_test2.svg" class="b">';
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg');
+
+        $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test');
+    }
+
+    public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder()
+    {
+        $page = $this->entities->page();
+        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg"/>'
+            . '<img src="http://localhost/uploads/svg_test.svg"/>'
+            . '<img src="/uploads/svg_test.svg"/>';
+        $storageDisk = Storage::disk('local');
+        $storageDisk->makeDirectory('uploads/images/gallery');
+        $storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
+        $storageDisk->put('uploads/svg_test.svg', '<svg>bad</svg>');
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+
+        $storageDisk->delete('uploads/images/gallery/svg_test.svg');
+        $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);
+    }
+
+    public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local()
+    {
+        $contents = file_get_contents(public_path('.htaccess'));
+        config()->set('filesystems.images', 'local');
+
+        $page = $this->entities->page();
+        $page->html = '<img src="http://localhost/uploads/images/../../.htaccess"/>';
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertDontSee(base64_encode($contents));
+    }
+
+    public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure()
+    {
+        $testFilePath = storage_path('logs/test.txt');
+        config()->set('filesystems.images', 'local_secure');
+        file_put_contents($testFilePath, 'I am a cat');
+
+        $page = $this->entities->page();
+        $page->html = '<img src="http://localhost/uploads/images/../../logs/test.txt"/>';
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertDontSee(base64_encode('I am a cat'));
+        unlink($testFilePath);
+    }
+
+    public function test_exports_removes_scripts_from_custom_head()
+    {
+        $entities = [
+            Page::query()->first(), Chapter::query()->first(), Book::query()->first(),
+        ];
+        setting()->put('app-custom-head', '<script>window.donkey = "cat";</script><style>.my-test-class { color: red; }</style>');
+
+        foreach ($entities as $entity) {
+            $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
+            $resp->assertDontSee('window.donkey');
+            $resp->assertDontSee('<script', false);
+            $resp->assertSee('.my-test-class { color: red; }');
+        }
+    }
+
+    public function test_page_export_with_deleted_creator_and_updater()
+    {
+        $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']);
+        $page = $this->entities->page();
+        $page->created_by = $user->id;
+        $page->updated_by = $user->id;
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertSee('ExportWizardTheFifth');
+
+        $user->delete();
+        $resp = $this->get($page->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertDontSee('ExportWizardTheFifth');
+    }
+
+    public function test_html_exports_contain_csp_meta_tag()
+    {
+        $entities = [
+            $this->entities->page(),
+            $this->entities->book(),
+            $this->entities->chapter(),
+        ];
+
+        foreach ($entities as $entity) {
+            $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
+            $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]');
+        }
+    }
+
+    public function test_html_exports_contain_body_classes_for_export_identification()
+    {
+        $page = $this->entities->page();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none');
+    }
+}
diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php
new file mode 100644 (file)
index 0000000..05ebbc6
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Book;
+use Tests\TestCase;
+
+class MarkdownExportTest extends TestCase
+{
+    public function test_page_markdown_export()
+    {
+        $page = $this->entities->page();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
+    }
+
+    public function test_page_markdown_export_uses_existing_markdown_if_apparent()
+    {
+        $page = $this->entities->page()->forceFill([
+            'markdown' => '# A header',
+            'html'     => '<h1>Dogcat</h1>',
+        ]);
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertSee('A header');
+        $resp->assertDontSee('Dogcat');
+    }
+
+    public function test_page_markdown_export_converts_html_where_no_markdown()
+    {
+        $page = $this->entities->page()->forceFill([
+            'markdown' => '',
+            'html'     => '<h1>Dogcat</h1><p>Some <strong>bold</strong> text</p>',
+        ]);
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertSee("# Dogcat\n\nSome **bold** text");
+    }
+
+    public function test_chapter_markdown_export()
+    {
+        $chapter = $this->entities->chapter();
+        $page = $chapter->pages()->first();
+        $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown'));
+
+        $resp->assertSee('# ' . $chapter->name);
+        $resp->assertSee('# ' . $page->name);
+    }
+
+    public function test_book_markdown_export()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
+        $chapter = $book->chapters()->first();
+        $page = $chapter->pages()->first();
+        $resp = $this->asEditor()->get($book->getUrl('/export/markdown'));
+
+        $resp->assertSee('# ' . $book->name);
+        $resp->assertSee('# ' . $chapter->name);
+        $resp->assertSee('# ' . $page->name);
+    }
+
+    public function test_book_markdown_export_concats_immediate_pages_with_newlines()
+    {
+        /** @var Book $book */
+        $book = Book::query()->whereHas('pages')->first();
+
+        $this->asEditor()->get($book->getUrl('/create-page'));
+        $this->get($book->getUrl('/create-page'));
+
+        [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get();
+        $pageA->html = '<p>hello tester</p>';
+        $pageA->save();
+        $pageB->name = 'The second page in this test';
+        $pageB->save();
+
+        $resp = $this->get($book->getUrl('/export/markdown'));
+        $resp->assertDontSee('hello tester# The second page in this test');
+        $resp->assertSee("hello tester\n\n# The second page in this test");
+    }
+}
diff --git a/tests/Exports/PdfExportTest.php b/tests/Exports/PdfExportTest.php
new file mode 100644 (file)
index 0000000..9d85c69
--- /dev/null
@@ -0,0 +1,146 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\PdfExportException;
+use BookStack\Exports\PdfGenerator;
+use Tests\TestCase;
+
+class PdfExportTest extends TestCase
+{
+    public function test_page_pdf_export()
+    {
+        $page = $this->entities->page();
+        $this->asEditor();
+
+        $resp = $this->get($page->getUrl('/export/pdf'));
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
+    }
+
+    public function test_book_pdf_export()
+    {
+        $page = $this->entities->page();
+        $book = $page->book;
+        $this->asEditor();
+
+        $resp = $this->get($book->getUrl('/export/pdf'));
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
+    }
+
+    public function test_chapter_pdf_export()
+    {
+        $chapter = $this->entities->chapter();
+        $this->asEditor();
+
+        $resp = $this->get($chapter->getUrl('/export/pdf'));
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
+    }
+
+
+    public function test_page_pdf_export_converts_iframes_to_links()
+    {
+        $page = Page::query()->first()->forceFill([
+            'html'     => '<iframe width="560" height="315" src="//www.youtube.com/embed/ShqUjt33uOs"></iframe>',
+        ]);
+        $page->save();
+
+        $pdfHtml = '';
+        $mockPdfGenerator = $this->mock(PdfGenerator::class);
+        $mockPdfGenerator->shouldReceive('fromHtml')
+            ->with(\Mockery::capture($pdfHtml))
+            ->andReturn('');
+        $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
+
+        $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $this->assertStringNotContainsString('iframe>', $pdfHtml);
+        $this->assertStringContainsString('<p><a href="https://www.youtube.com/embed/ShqUjt33uOs">https://www.youtube.com/embed/ShqUjt33uOs</a></p>', $pdfHtml);
+    }
+
+    public function test_page_pdf_export_opens_details_blocks()
+    {
+        $page = $this->entities->page()->forceFill([
+            'html'     => '<details><summary>Hello</summary><p>Content!</p></details>',
+        ]);
+        $page->save();
+
+        $pdfHtml = '';
+        $mockPdfGenerator = $this->mock(PdfGenerator::class);
+        $mockPdfGenerator->shouldReceive('fromHtml')
+            ->with(\Mockery::capture($pdfHtml))
+            ->andReturn('');
+        $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
+
+        $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $this->assertStringContainsString('<details open="open"', $pdfHtml);
+    }
+
+    public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
+    {
+        $page = $this->entities->page();
+
+        config()->set('exports.snappy.pdf_binary', '/abc123');
+        config()->set('app.allow_untrusted_server_fetching', false);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage.
+
+        config()->set('app.allow_untrusted_server_fetching', true);
+        $resp = $this->get($page->getUrl('/export/pdf'));
+        $resp->assertStatus(500); // Bad response indicates wkhtml usage
+    }
+
+    public function test_pdf_command_option_used_if_set()
+    {
+        $page = $this->entities->page();
+        $command = 'cp {input_html_path} {output_pdf_path}';
+        config()->set('exports.pdf_command', $command);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $download = $resp->getContent();
+
+        $this->assertStringContainsString(e($page->name), $download);
+        $this->assertStringContainsString('<html lang=', $download);
+    }
+
+    public function test_pdf_command_option_errors_if_output_path_not_written_to()
+    {
+        $page = $this->entities->page();
+        $command = 'echo "hi"';
+        config()->set('exports.pdf_command', $command);
+
+        $this->assertThrows(function () use ($page) {
+            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
+        }, PdfExportException::class);
+    }
+
+    public function test_pdf_command_option_errors_if_command_returns_error_status()
+    {
+        $page = $this->entities->page();
+        $command = 'exit 1';
+        config()->set('exports.pdf_command', $command);
+
+        $this->assertThrows(function () use ($page) {
+            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
+        }, PdfExportException::class);
+    }
+
+    public function test_pdf_command_timout_option_limits_export_time()
+    {
+        $page = $this->entities->page();
+        $command = 'php -r \'sleep(4);\'';
+        config()->set('exports.pdf_command', $command);
+        config()->set('exports.pdf_command_timeout', 1);
+
+        $this->assertThrows(function () use ($page) {
+            $start = time();
+            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
+
+            $this->assertTrue(time() < ($start + 3));
+        }, PdfExportException::class,
+            "PDF Export via command failed due to timeout at 1 second(s)");
+    }
+}
diff --git a/tests/Exports/TextExportTest.php b/tests/Exports/TextExportTest.php
new file mode 100644 (file)
index 0000000..c593a65
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+namespace Tests\Exports;
+
+use Tests\TestCase;
+
+class TextExportTest extends TestCase
+{
+    public function test_page_text_export()
+    {
+        $page = $this->entities->page();
+        $this->asEditor();
+
+        $resp = $this->get($page->getUrl('/export/plaintext'));
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
+    }
+
+    public function test_book_text_export()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $directPage = $book->directPages()->first();
+        $chapter = $book->chapters()->first();
+        $chapterPage = $chapter->pages()->first();
+        $this->entities->updatePage($directPage, ['html' => '<p>My awesome page</p>']);
+        $this->entities->updatePage($chapterPage, ['html' => '<p>My little nested page</p>']);
+        $this->asEditor();
+
+        $resp = $this->get($book->getUrl('/export/plaintext'));
+        $resp->assertStatus(200);
+        $resp->assertSee($book->name);
+        $resp->assertSee($chapterPage->name);
+        $resp->assertSee($chapter->name);
+        $resp->assertSee($directPage->name);
+        $resp->assertSee('My awesome page');
+        $resp->assertSee('My little nested page');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
+    }
+
+    public function test_book_text_export_format()
+    {
+        $entities = $this->entities->createChainBelongingToUser($this->users->viewer());
+        $this->entities->updatePage($entities['page'], ['html' => '<p>My great page</p><p>Full of <strong>great</strong> stuff</p>', 'name' => 'My wonderful page!']);
+        $entities['chapter']->name = 'Export chapter';
+        $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within";
+        $entities['book']->name = 'Export Book';
+        $entities['book']->description = "This is a book with stuff to export";
+        $entities['chapter']->save();
+        $entities['book']->save();
+
+        $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";
+        $resp->assertSee($expected);
+    }
+
+    public function test_chapter_text_export()
+    {
+        $chapter = $this->entities->chapter();
+        $page = $chapter->pages[0];
+        $this->entities->updatePage($page, ['html' => '<p>This is content within the page!</p>']);
+        $this->asEditor();
+
+        $resp = $this->get($chapter->getUrl('/export/plaintext'));
+        $resp->assertStatus(200);
+        $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"');
+    }
+
+    public function test_chapter_text_export_format()
+    {
+        $entities = $this->entities->createChainBelongingToUser($this->users->viewer());
+        $this->entities->updatePage($entities['page'], ['html' => '<p>My great page</p><p>Full of <strong>great</strong> stuff</p>', 'name' => 'My wonderful page!']);
+        $entities['chapter']->name = 'Export chapter';
+        $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within";
+        $entities['chapter']->save();
+
+        $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";
+        $resp->assertSee($expected);
+    }
+}
diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php
new file mode 100644 (file)
index 0000000..ebe07d0
--- /dev/null
@@ -0,0 +1,403 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Activity\Models\Tag;
+use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Uploads\Attachment;
+use BookStack\Uploads\Image;
+use Illuminate\Support\Carbon;
+use Illuminate\Testing\TestResponse;
+use Tests\TestCase;
+use ZipArchive;
+
+class ZipExportTest extends TestCase
+{
+    public function test_export_results_in_zip_format()
+    {
+        $page = $this->entities->page();
+        $response = $this->asEditor()->get($page->getUrl("/export/zip"));
+
+        $zipData = $response->streamedContent();
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstesta-');
+        file_put_contents($zipFile, $zipData);
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::RDONLY);
+
+        $this->assertNotFalse($zip->locateName('data.json'));
+        $this->assertNotFalse($zip->locateName('files/'));
+
+        $data = json_decode($zip->getFromName('data.json'), true);
+        $this->assertIsArray($data);
+        $this->assertGreaterThan(0, count($data));
+
+        $zip->close();
+        unlink($zipFile);
+    }
+
+    public function test_export_metadata()
+    {
+        $page = $this->entities->page();
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $this->assertEquals($page->id, $zip->data['page']['id'] ?? null);
+        $this->assertArrayNotHasKey('book', $zip->data);
+        $this->assertArrayNotHasKey('chapter', $zip->data);
+
+        $now = time();
+        $date = Carbon::parse($zip->data['exported_at'])->unix();
+        $this->assertLessThan($now + 2, $date);
+        $this->assertGreaterThan($now - 2, $date);
+
+        $version = trim(file_get_contents(base_path('version')));
+        $this->assertEquals($version, $zip->data['instance']['version']);
+
+        $zipInstanceId = $zip->data['instance']['id'];
+        $instanceId = setting('instance-id');
+        $this->assertNotEmpty($instanceId);
+        $this->assertEquals($instanceId, $zipInstanceId);
+    }
+
+    public function test_page_export()
+    {
+        $page = $this->entities->page();
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $pageData = $zip->data['page'];
+        $this->assertEquals([
+            'id' => $page->id,
+            'name' => $page->name,
+            'html' => (new PageContent($page))->render(),
+            'priority' => $page->priority,
+            'attachments' => [],
+            'images' => [],
+            'tags' => [],
+        ], $pageData);
+    }
+
+    public function test_page_export_with_markdown()
+    {
+        $page = $this->entities->page();
+        $markdown = "# My page\n\nwritten in markdown for export\n";
+        $page->markdown = $markdown;
+        $page->save();
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $pageData = $zip->data['page'];
+        $this->assertEquals($markdown, $pageData['markdown']);
+        $this->assertNotEmpty($pageData['html']);
+    }
+
+    public function test_page_export_with_tags()
+    {
+        $page = $this->entities->page();
+        $page->tags()->saveMany([
+            new Tag(['name' => 'Exporty', 'value' => 'Content', 'order' => 1]),
+            new Tag(['name' => 'Another', 'value' => '', 'order' => 2]),
+        ]);
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $pageData = $zip->data['page'];
+        $this->assertEquals([
+            [
+                'name' => 'Exporty',
+                'value' => 'Content',
+            ],
+            [
+                'name' => 'Another',
+                'value' => '',
+            ]
+        ], $pageData['tags']);
+    }
+
+    public function test_page_export_with_images()
+    {
+        $this->asEditor();
+        $page = $this->entities->page();
+        $result = $this->files->uploadGalleryImageToPage($this, $page);
+        $displayThumb = $result['response']->thumbs->gallery ?? '';
+        $page->html = '<p><img src="' . $displayThumb . '" alt="My image"></p>';
+        $page->save();
+        $image = Image::findOrFail($result['response']->id);
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $pageData = $zip->data['page'];
+
+        $this->assertCount(1, $pageData['images']);
+        $imageData = $pageData['images'][0];
+        $this->assertEquals($image->id, $imageData['id']);
+        $this->assertEquals($image->name, $imageData['name']);
+        $this->assertEquals('gallery', $imageData['type']);
+        $this->assertNotEmpty($imageData['file']);
+
+        $filePath = $zip->extractPath("files/{$imageData['file']}");
+        $this->assertFileExists($filePath);
+        $this->assertEquals(file_get_contents(public_path($image->path)), file_get_contents($filePath));
+
+        $this->assertEquals('<p><img src="[[bsexport:image:' . $imageData['id'] . ']]" alt="My image"></p>', $pageData['html']);
+    }
+
+    public function test_page_export_file_attachments()
+    {
+        $contents = 'My great attachment content!';
+
+        $page = $this->entities->page();
+        $this->asAdmin();
+        $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'PageAttachmentExport.txt', $contents, 'text/plain');
+
+        $zipResp = $this->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $pageData = $zip->data['page'];
+        $this->assertCount(1, $pageData['attachments']);
+
+        $attachmentData = $pageData['attachments'][0];
+        $this->assertEquals('PageAttachmentExport.txt', $attachmentData['name']);
+        $this->assertEquals($attachment->id, $attachmentData['id']);
+        $this->assertArrayNotHasKey('link', $attachmentData);
+        $this->assertNotEmpty($attachmentData['file']);
+
+        $fileRef = $attachmentData['file'];
+        $filePath = $zip->extractPath("/files/$fileRef");
+        $this->assertFileExists($filePath);
+        $this->assertEquals($contents, file_get_contents($filePath));
+    }
+
+    public function test_page_export_link_attachments()
+    {
+        $page = $this->entities->page();
+        $this->asEditor();
+        $attachment = Attachment::factory()->create([
+            'name' => 'My link attachment for export',
+            'path' => 'https://example.com/cats',
+            'external' => true,
+            'uploaded_to' => $page->id,
+            'order' => 1,
+        ]);
+
+        $zipResp = $this->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $pageData = $zip->data['page'];
+        $this->assertCount(1, $pageData['attachments']);
+
+        $attachmentData = $pageData['attachments'][0];
+        $this->assertEquals('My link attachment for export', $attachmentData['name']);
+        $this->assertEquals($attachment->id, $attachmentData['id']);
+        $this->assertEquals('https://example.com/cats', $attachmentData['link']);
+        $this->assertArrayNotHasKey('file', $attachmentData);
+    }
+
+    public function test_book_export()
+    {
+        $book = $this->entities->book();
+        $book->tags()->saveMany(Tag::factory()->count(2)->make());
+
+        $zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $this->assertArrayHasKey('book', $zip->data);
+
+        $bookData = $zip->data['book'];
+        $this->assertEquals($book->id, $bookData['id']);
+        $this->assertEquals($book->name, $bookData['name']);
+        $this->assertEquals($book->descriptionHtml(), $bookData['description_html']);
+        $this->assertCount(2, $bookData['tags']);
+        $this->assertCount($book->directPages()->count(), $bookData['pages']);
+        $this->assertCount($book->chapters()->count(), $bookData['chapters']);
+        $this->assertArrayNotHasKey('cover', $bookData);
+    }
+
+    public function test_book_export_with_cover_image()
+    {
+        $book = $this->entities->book();
+        $bookRepo = $this->app->make(BookRepo::class);
+        $coverImageFile = $this->files->uploadedImage('cover.png');
+        $bookRepo->updateCoverImage($book, $coverImageFile);
+        $coverImage = $book->cover()->first();
+
+        $zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $this->assertArrayHasKey('cover', $zip->data['book']);
+        $coverRef = $zip->data['book']['cover'];
+        $coverPath = $zip->extractPath("/files/$coverRef");
+        $this->assertFileExists($coverPath);
+        $this->assertEquals(file_get_contents(public_path($coverImage->path)), file_get_contents($coverPath));
+    }
+
+    public function test_chapter_export()
+    {
+        $chapter = $this->entities->chapter();
+        $chapter->tags()->saveMany(Tag::factory()->count(2)->make());
+
+        $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $this->assertArrayHasKey('chapter', $zip->data);
+
+        $chapterData = $zip->data['chapter'];
+        $this->assertEquals($chapter->id, $chapterData['id']);
+        $this->assertEquals($chapter->name, $chapterData['name']);
+        $this->assertEquals($chapter->descriptionHtml(), $chapterData['description_html']);
+        $this->assertCount(2, $chapterData['tags']);
+        $this->assertEquals($chapter->priority, $chapterData['priority']);
+        $this->assertCount($chapter->pages()->count(), $chapterData['pages']);
+    }
+
+
+    public function test_cross_reference_links_are_converted()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $chapter = $book->chapters()->first();
+        $page = $chapter->pages()->first();
+
+        $book->description_html = '<p><a href="' . $chapter->getUrl() . '">Link to chapter</a></p>';
+        $book->save();
+        $chapter->description_html = '<p><a href="' . $page->getUrl() . '#section2">Link to page</a></p>';
+        $chapter->save();
+        $page->html = '<p><a href="' . $book->getUrl() . '?view=true">Link to book</a></p>';
+        $page->save();
+
+        $zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $bookData = $zip->data['book'];
+        $chapterData = $bookData['chapters'][0];
+        $pageData = $chapterData['pages'][0];
+
+        $this->assertStringContainsString('href="[[bsexport:chapter:' . $chapter->id . ']]"', $bookData['description_html']);
+        $this->assertStringContainsString('href="[[bsexport:page:' . $page->id . ']]#section2"', $chapterData['description_html']);
+        $this->assertStringContainsString('href="[[bsexport:book:' . $book->id . ']]?view=true"', $pageData['html']);
+    }
+
+    public function test_book_and_chapter_description_links_to_images_in_pages_are_converted()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $chapter = $book->chapters()->first();
+        $page = $chapter->pages()->first();
+
+        $this->asEditor();
+        $this->files->uploadGalleryImageToPage($this, $page);
+        /** @var Image $image */
+        $image = Image::query()->where('type', '=', 'gallery')
+            ->where('uploaded_to', '=', $page->id)->first();
+
+        $book->description_html = '<p><a href="' . $image->url . '">Link to image</a></p>';
+        $book->save();
+        $chapter->description_html = '<p><a href="' . $image->url . '">Link to image</a></p>';
+        $chapter->save();
+
+        $zipResp = $this->get($book->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $bookData = $zip->data['book'];
+        $chapterData = $bookData['chapters'][0];
+
+        $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $bookData['description_html']);
+        $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $chapterData['description_html']);
+    }
+
+    public function test_image_links_are_handled_when_using_external_storage_url()
+    {
+        $page = $this->entities->page();
+
+        $this->asEditor();
+        $this->files->uploadGalleryImageToPage($this, $page);
+        /** @var Image $image */
+        $image = Image::query()->where('type', '=', 'gallery')
+            ->where('uploaded_to', '=', $page->id)->first();
+
+        config()->set('filesystems.url', 'https://i.example.com/content');
+
+        $storageUrl = 'https://i.example.com/content/' . ltrim($image->path, '/');
+        $page->html = '<p><a href="' . $image->url . '">Original URL</a><a href="' . $storageUrl . '">Storage URL</a></p>';
+        $page->save();
+
+        $zipResp = $this->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $pageData = $zip->data['page'];
+
+        $ref = '[[bsexport:image:' . $image->id . ']]';
+        $this->assertStringContainsString("<a href=\"{$ref}\">Original URL</a><a href=\"{$ref}\">Storage URL</a>", $pageData['html']);
+    }
+
+    public function test_cross_reference_links_external_to_export_are_not_converted()
+    {
+        $page = $this->entities->page();
+        $page->html = '<p><a href="' . $page->book->getUrl() . '">Link to book</a></p>';
+        $page->save();
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $pageData = $zip->data['page'];
+
+        $this->assertStringContainsString('href="' . $page->book->getUrl() . '"', $pageData['html']);
+    }
+
+    public function test_attachments_links_are_converted()
+    {
+        $page = $this->entities->page();
+        $attachment = Attachment::factory()->create([
+            'name' => 'My link attachment for export reference',
+            'path' => 'https://example.com/cats/ref',
+            'external' => true,
+            'uploaded_to' => $page->id,
+            'order' => 1,
+        ]);
+
+        $page->html = '<p><a href="' . url("/attachments/{$attachment->id}") . '?open=true">Link to attachment</a></p>';
+        $page->save();
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $pageData = $zip->data['page'];
+
+        $this->assertStringContainsString('href="[[bsexport:attachment:' . $attachment->id . ']]?open=true"', $pageData['html']);
+    }
+
+    public function test_links_in_markdown_are_parsed()
+    {
+        $chapter = $this->entities->chapterHasPages();
+        $page = $chapter->pages()->first();
+
+        $page->markdown = "[Link to chapter]({$chapter->getUrl()})";
+        $page->save();
+
+        $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $pageData = $zip->data['chapter']['pages'][0];
+
+        $this->assertStringContainsString("[Link to chapter]([[bsexport:chapter:{$chapter->id}]])", $pageData['markdown']);
+    }
+
+    protected function extractZipResponse(TestResponse $response): ZipResultData
+    {
+        $zipData = $response->streamedContent();
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
+
+        file_put_contents($zipFile, $zipData);
+        $extractDir = tempnam(sys_get_temp_dir(), 'bstestextracted-');
+        if (file_exists($extractDir)) {
+            unlink($extractDir);
+        }
+        mkdir($extractDir);
+
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::RDONLY);
+        $zip->extractTo($extractDir);
+
+        $dataJson = file_get_contents($extractDir . DIRECTORY_SEPARATOR . "data.json");
+        $data = json_decode($dataJson, true);
+
+        return new ZipResultData(
+            $zipFile,
+            $extractDir,
+            $data,
+        );
+    }
+}
diff --git a/tests/Exports/ZipExportValidatorTest.php b/tests/Exports/ZipExportValidatorTest.php
new file mode 100644 (file)
index 0000000..c453ef2
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Exports\ZipExports\ZipExportReader;
+use BookStack\Exports\ZipExports\ZipExportValidator;
+use BookStack\Exports\ZipExports\ZipImportRunner;
+use BookStack\Uploads\Image;
+use Tests\TestCase;
+
+class ZipExportValidatorTest extends TestCase
+{
+    protected array $filesToRemove = [];
+
+    protected function tearDown(): void
+    {
+        foreach ($this->filesToRemove as $file) {
+            unlink($file);
+        }
+
+        parent::tearDown();
+    }
+
+    protected function getValidatorForData(array $zipData, array $files = []): ZipExportValidator
+    {
+        $upload = ZipTestHelper::zipUploadFromData($zipData, $files);
+        $path = $upload->getRealPath();
+        $this->filesToRemove[] = $path;
+        $reader = new ZipExportReader($path);
+        return new ZipExportValidator($reader);
+    }
+
+    public function test_ids_have_to_be_unique()
+    {
+        $validator = $this->getValidatorForData([
+            'book' => [
+                'id' => 4,
+                'name' => 'My book',
+                'pages' => [
+                    [
+                        'id' => 4,
+                        'name' => 'My page',
+                        'markdown' => 'hello',
+                        'attachments' => [
+                            ['id' => 4, 'name' => 'Attachment A', 'link' => 'https://example.com'],
+                            ['id' => 4, 'name' => 'Attachment B', 'link' => 'https://example.com']
+                        ],
+                        'images' => [
+                            ['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'],
+                            ['id' => 4, 'name' => 'Image b', 'type' => 'gallery', 'file' => 'cat'],
+                        ],
+                    ],
+                    ['id' => 4, 'name' => 'My page', 'markdown' => 'hello'],
+                ],
+                'chapters' => [
+                    ['id' => 4, 'name' => 'Chapter 1'],
+                    ['id' => 4, 'name' => 'Chapter 2']
+                ]
+            ]
+        ], ['cat' => $this->files->testFilePath('test-image.png')]);
+
+        $results = $validator->validate();
+        $this->assertCount(4, $results);
+
+        $expectedMessage = 'The id must be unique for the object type within the ZIP.';
+        $this->assertEquals($expectedMessage, $results['book.pages.0.attachments.1.id']);
+        $this->assertEquals($expectedMessage, $results['book.pages.0.images.1.id']);
+        $this->assertEquals($expectedMessage, $results['book.pages.1.id']);
+        $this->assertEquals($expectedMessage, $results['book.chapters.1.id']);
+    }
+
+    public function test_image_files_need_to_be_a_valid_detected_image_file()
+    {
+        $validator = $this->getValidatorForData([
+            'page' => [
+                'id' => 4,
+                'name' => 'My page',
+                'markdown' => 'hello',
+                'images' => [
+                    ['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'],
+                ],
+            ]
+        ], ['cat' => $this->files->testFilePath('test-file.txt')]);
+
+        $results = $validator->validate();
+        $this->assertCount(1, $results);
+
+        $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']);
+    }
+}
diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php
new file mode 100644 (file)
index 0000000..d3af6df
--- /dev/null
@@ -0,0 +1,396 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Exports\ZipExports\ZipImportRunner;
+use BookStack\Uploads\Image;
+use Tests\TestCase;
+
+class ZipImportRunnerTest extends TestCase
+{
+    protected ZipImportRunner $runner;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->runner = app()->make(ZipImportRunner::class);
+    }
+
+    public function test_book_import()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $testFilePath = $this->files->testFilePath('test-file.txt');
+        $import = ZipTestHelper::importFromData([], [
+            'book' => [
+                'id' => 5,
+                'name' => 'Import test',
+                'cover' => 'book_cover_image',
+                'description_html' => '<p><a href="[[bsexport:page:3]]">Link to chapter page</a></p>',
+                'tags' => [
+                    ['name' => 'Animal', 'value' => 'Cat'],
+                    ['name' => 'Category', 'value' => 'Test'],
+                ],
+                'chapters' => [
+                    [
+                        'id' => 6,
+                        'name' => 'Chapter A',
+                        'description_html' => '<p><a href="[[bsexport:book:5]]">Link to book</a></p>',
+                        'priority' => 1,
+                        'tags' => [
+                            ['name' => 'Reviewed'],
+                            ['name' => 'Category', 'value' => 'Test Chapter'],
+                        ],
+                        'pages' => [
+                            [
+                                'id' => 3,
+                                'name' => 'Page A',
+                                'priority' => 6,
+                                'html' => '
+<p><a href="[[bsexport:page:3]]">Link to self</a></p>
+<p><a href="[[bsexport:image:1]]">Link to cat image</a></p>
+<p><a href="[[bsexport:attachment:4]]">Link to text attachment</a></p>',
+                                'tags' => [
+                                    ['name' => 'Unreviewed'],
+                                ],
+                                'attachments' => [
+                                    [
+                                        'id' => 4,
+                                        'name' => 'Text attachment',
+                                        'file' => 'file_attachment'
+                                    ],
+                                    [
+                                        'name' => 'Cats',
+                                        'link' => 'https://example.com/cats',
+                                    ]
+                                ],
+                                'images' => [
+                                    [
+                                        'id' => 1,
+                                        'name' => 'Cat',
+                                        'type' => 'gallery',
+                                        'file' => 'cat_image'
+                                    ],
+                                    [
+                                        'id' => 2,
+                                        'name' => 'Dog Drawing',
+                                        'type' => 'drawio',
+                                        'file' => 'dog_image'
+                                    ]
+                                ],
+                            ],
+                        ],
+                    ],
+                    [
+                        'name' => 'Chapter child B',
+                        'priority' => 5,
+                    ]
+                ],
+                'pages' => [
+                    [
+                        'name' => 'Page C',
+                        'markdown' => '[Link to text]([[bsexport:attachment:4]]?scale=big)',
+                        'priority' => 3,
+                    ]
+                ],
+            ],
+        ], [
+            'book_cover_image' => $testImagePath,
+            'file_attachment'  => $testFilePath,
+            'cat_image' => $testImagePath,
+            'dog_image' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Book $book */
+        $book = $this->runner->run($import);
+
+        // Book checks
+        $this->assertEquals('Import test', $book->name);
+        $this->assertFileExists(public_path($book->cover->path));
+        $this->assertCount(2, $book->tags);
+        $this->assertEquals('Cat', $book->tags()->first()->value);
+        $this->assertCount(2, $book->chapters);
+        $this->assertEquals(1, $book->directPages()->count());
+
+        // Chapter checks
+        $chapterA = $book->chapters()->where('name', 'Chapter A')->first();
+        $this->assertCount(2, $chapterA->tags);
+        $firstChapterTag = $chapterA->tags()->first();
+        $this->assertEquals('Reviewed', $firstChapterTag->name);
+        $this->assertEquals('', $firstChapterTag->value);
+        $this->assertCount(1, $chapterA->pages);
+
+        // Page checks
+        /** @var Page $pageA */
+        $pageA = $chapterA->pages->first();
+        $this->assertEquals('Page A', $pageA->name);
+        $this->assertCount(1, $pageA->tags);
+        $firstPageTag = $pageA->tags()->first();
+        $this->assertEquals('Unreviewed', $firstPageTag->name);
+        $this->assertCount(2, $pageA->attachments);
+        $firstAttachment = $pageA->attachments->first();
+        $this->assertEquals('Text attachment', $firstAttachment->name);
+        $this->assertFileEquals($testFilePath, storage_path($firstAttachment->path));
+        $this->assertFalse($firstAttachment->external);
+        $secondAttachment = $pageA->attachments->last();
+        $this->assertEquals('Cats', $secondAttachment->name);
+        $this->assertEquals('https://example.com/cats', $secondAttachment->path);
+        $this->assertTrue($secondAttachment->external);
+        $pageAImages = Image::where('uploaded_to', '=', $pageA->id)->whereIn('type', ['gallery', 'drawio'])->get();
+        $this->assertCount(2, $pageAImages);
+        $this->assertEquals('Cat', $pageAImages[0]->name);
+        $this->assertEquals('gallery', $pageAImages[0]->type);
+        $this->assertFileEquals($testImagePath, public_path($pageAImages[0]->path));
+        $this->assertEquals('Dog Drawing', $pageAImages[1]->name);
+        $this->assertEquals('drawio', $pageAImages[1]->type);
+
+        // Book order check
+        $children = $book->getDirectVisibleChildren()->values()->all();
+        $this->assertEquals($children[0]->name, 'Chapter A');
+        $this->assertEquals($children[1]->name, 'Page C');
+        $this->assertEquals($children[2]->name, 'Chapter child B');
+
+        // Reference checks
+        $textAttachmentUrl = $firstAttachment->getUrl();
+        $this->assertStringContainsString($pageA->getUrl(), $book->description_html);
+        $this->assertStringContainsString($book->getUrl(), $chapterA->description_html);
+        $this->assertStringContainsString($pageA->getUrl(), $pageA->html);
+        $this->assertStringContainsString($pageAImages[0]->getThumb(1680, null, true), $pageA->html);
+        $this->assertStringContainsString($firstAttachment->getUrl(), $pageA->html);
+
+        // Reference in converted markdown
+        $pageC = $children[1];
+        $this->assertStringContainsString("href=\"{$textAttachmentUrl}?scale=big\"", $pageC->html);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_chapter_import()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $testFilePath = $this->files->testFilePath('test-file.txt');
+        $parent = $this->entities->book();
+
+        $import = ZipTestHelper::importFromData([], [
+            'chapter' => [
+                'id' => 6,
+                'name' => 'Chapter A',
+                'description_html' => '<p><a href="[[bsexport:page:3]]">Link to page</a></p>',
+                'priority' => 1,
+                'tags' => [
+                    ['name' => 'Reviewed', 'value' => '2024'],
+                ],
+                'pages' => [
+                    [
+                        'id' => 3,
+                        'name' => 'Page A',
+                        'priority' => 6,
+                        'html' => '<p><a href="[[bsexport:chapter:6]]">Link to chapter</a></p>
+<p><a href="[[bsexport:image:2]]">Link to dog drawing</a></p>
+<p><a href="[[bsexport:attachment:4]]">Link to text attachment</a></p>',
+                        'tags' => [
+                            ['name' => 'Unreviewed'],
+                        ],
+                        'attachments' => [
+                            [
+                                'id' => 4,
+                                'name' => 'Text attachment',
+                                'file' => 'file_attachment'
+                            ]
+                        ],
+                        'images' => [
+                            [
+                                'id' => 2,
+                                'name' => 'Dog Drawing',
+                                'type' => 'drawio',
+                                'file' => 'dog_image'
+                            ]
+                        ],
+                    ],
+                    [
+                        'name' => 'Page B',
+                        'markdown' => '[Link to page A]([[bsexport:page:3]])',
+                        'priority' => 9,
+                    ],
+                ],
+            ],
+        ], [
+            'file_attachment'  => $testFilePath,
+            'dog_image' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Chapter $chapter */
+        $chapter = $this->runner->run($import, $parent);
+
+        // Chapter checks
+        $this->assertEquals('Chapter A', $chapter->name);
+        $this->assertEquals($parent->id, $chapter->book_id);
+        $this->assertCount(1, $chapter->tags);
+        $firstChapterTag = $chapter->tags()->first();
+        $this->assertEquals('Reviewed', $firstChapterTag->name);
+        $this->assertEquals('2024', $firstChapterTag->value);
+        $this->assertCount(2, $chapter->pages);
+
+        // Page checks
+        /** @var Page $pageA */
+        $pageA = $chapter->pages->first();
+        $this->assertEquals('Page A', $pageA->name);
+        $this->assertCount(1, $pageA->tags);
+        $this->assertCount(1, $pageA->attachments);
+        $pageAImages = Image::where('uploaded_to', '=', $pageA->id)->whereIn('type', ['gallery', 'drawio'])->get();
+        $this->assertCount(1, $pageAImages);
+
+        // Reference checks
+        $attachment = $pageA->attachments->first();
+        $this->assertStringContainsString($pageA->getUrl(), $chapter->description_html);
+        $this->assertStringContainsString($chapter->getUrl(), $pageA->html);
+        $this->assertStringContainsString($pageAImages[0]->url, $pageA->html);
+        $this->assertStringContainsString($attachment->getUrl(), $pageA->html);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_page_import()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $testFilePath = $this->files->testFilePath('test-file.txt');
+        $parent = $this->entities->chapter();
+
+        $import = ZipTestHelper::importFromData([], [
+            'page' => [
+                'id' => 3,
+                'name' => 'Page A',
+                'priority' => 6,
+                'html' => '<p><a href="[[bsexport:page:3]]">Link to self</a></p>
+<p><a href="[[bsexport:image:2]]">Link to dog drawing</a></p>
+<p><a href="[[bsexport:attachment:4]]">Link to text attachment</a></p>',
+                'tags' => [
+                    ['name' => 'Unreviewed'],
+                ],
+                'attachments' => [
+                    [
+                        'id' => 4,
+                        'name' => 'Text attachment',
+                        'file' => 'file_attachment'
+                    ]
+                ],
+                'images' => [
+                    [
+                        'id' => 2,
+                        'name' => 'Dog Drawing',
+                        'type' => 'drawio',
+                        'file' => 'dog_image'
+                    ]
+                ],
+            ],
+        ], [
+            'file_attachment'  => $testFilePath,
+            'dog_image' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Page $page */
+        $page = $this->runner->run($import, $parent);
+
+        // Page checks
+        $this->assertEquals('Page A', $page->name);
+        $this->assertCount(1, $page->tags);
+        $this->assertCount(1, $page->attachments);
+        $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get();
+        $this->assertCount(1, $pageImages);
+        $this->assertFileEquals($testImagePath, public_path($pageImages[0]->path));
+
+        // Reference checks
+        $this->assertStringContainsString($page->getUrl(), $page->html);
+        $this->assertStringContainsString($pageImages[0]->url, $page->html);
+        $this->assertStringContainsString($page->attachments->first()->getUrl(), $page->html);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_revert_cleans_up_uploaded_files()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $testFilePath = $this->files->testFilePath('test-file.txt');
+        $parent = $this->entities->chapter();
+
+        $import = ZipTestHelper::importFromData([], [
+            'page' => [
+                'name' => 'Page A',
+                'html' => '<p>Hello</p>',
+                'attachments' => [
+                    [
+                        'name' => 'Text attachment',
+                        'file' => 'file_attachment'
+                    ]
+                ],
+                'images' => [
+                    [
+                        'name' => 'Dog Image',
+                        'type' => 'gallery',
+                        'file' => 'dog_image'
+                    ]
+                ],
+            ],
+        ], [
+            'file_attachment'  => $testFilePath,
+            'dog_image' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Page $page */
+        $page = $this->runner->run($import, $parent);
+
+        $attachment = $page->attachments->first();
+        $image = Image::query()->where('uploaded_to', '=', $page->id)->where('type', '=', 'gallery')->first();
+
+        $this->assertFileExists(public_path($image->path));
+        $this->assertFileExists(storage_path($attachment->path));
+
+        $this->runner->revertStoredFiles();
+
+        $this->assertFileDoesNotExist(public_path($image->path));
+        $this->assertFileDoesNotExist(storage_path($attachment->path));
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_imported_images_have_their_detected_extension_added()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $parent = $this->entities->chapter();
+
+        $import = ZipTestHelper::importFromData([], [
+            'page' => [
+                'name' => 'Page A',
+                'html' => '<p>hello</p>',
+                'images' => [
+                    [
+                        'id' => 2,
+                        'name' => 'Cat',
+                        'type' => 'gallery',
+                        'file' => 'cat_image'
+                    ]
+                ],
+            ],
+        ], [
+            'cat_image' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Page $page */
+        $page = $this->runner->run($import, $parent);
+
+        $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get();
+
+        $this->assertCount(1, $pageImages);
+        $this->assertStringEndsWith('.png', $pageImages[0]->url);
+        $this->assertStringEndsWith('.png', $pageImages[0]->path);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+}
diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php
new file mode 100644 (file)
index 0000000..ad0e6b2
--- /dev/null
@@ -0,0 +1,396 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Exports\Import;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Testing\TestResponse;
+use Tests\TestCase;
+use ZipArchive;
+
+class ZipImportTest extends TestCase
+{
+    public function test_import_page_view()
+    {
+        $resp = $this->asAdmin()->get('/import');
+        $resp->assertSee('Import');
+        $this->withHtml($resp)->assertElementExists('form input[type="file"][name="file"]');
+    }
+
+    public function test_permissions_needed_for_import_page()
+    {
+        $user = $this->users->viewer();
+        $this->actingAs($user);
+
+        $resp = $this->get('/books');
+        $this->withHtml($resp)->assertLinkNotExists(url('/import'));
+        $resp = $this->get('/import');
+        $resp->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $resp = $this->get('/books');
+        $this->withHtml($resp)->assertLinkExists(url('/import'));
+        $resp = $this->get('/import');
+        $resp->assertOk();
+        $resp->assertSeeText('Select ZIP file to upload');
+    }
+
+    public function test_import_page_pending_import_visibility_limited()
+    {
+        $user = $this->users->viewer();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $resp = $this->actingAs($user)->get('/import');
+        $resp->assertSeeText('MySuperUserImport');
+        $resp->assertDontSeeText('MySuperAdminImport');
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $resp = $this->actingAs($user)->get('/import');
+        $resp->assertSeeText('MySuperUserImport');
+        $resp->assertSeeText('MySuperAdminImport');
+    }
+
+    public function test_zip_read_errors_are_shown_on_validation()
+    {
+        $invalidUpload = $this->files->uploadedImage('image.zip');
+
+        $this->asAdmin();
+        $resp = $this->runImportFromFile($invalidUpload);
+        $resp->assertRedirect('/import');
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('Could not read ZIP file');
+    }
+
+    public function test_error_shown_if_missing_data()
+    {
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::CREATE);
+        $zip->addFromString('beans', 'cat');
+        $zip->close();
+
+        $this->asAdmin();
+        $upload = new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true);
+        $resp = $this->runImportFromFile($upload);
+        $resp->assertRedirect('/import');
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('Could not find and decode ZIP data.json content.');
+    }
+
+    public function test_error_shown_if_no_importable_key()
+    {
+        $this->asAdmin();
+        $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData([
+            'instance' => []
+        ]));
+
+        $resp->assertRedirect('/import');
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('ZIP file data has no expected book, chapter or page content.');
+    }
+
+    public function test_zip_data_validation_messages_shown()
+    {
+        $this->asAdmin();
+        $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData([
+            'book' => [
+                'id' => 4,
+                'pages' => [
+                    'cat',
+                    [
+                        'name' => 'My inner page',
+                        'tags' => [
+                            [
+                                'value' => 5
+                            ]
+                        ],
+                    ]
+                ],
+            ]
+        ]));
+
+        $resp->assertRedirect('/import');
+        $resp = $this->followRedirects($resp);
+
+        $resp->assertSeeText('[book.name]: The name field is required.');
+        $resp->assertSeeText('[book.pages.0.0]: Data object expected but "string" found.');
+        $resp->assertSeeText('[book.pages.1.tags.0.name]: The name field is required.');
+        $resp->assertSeeText('[book.pages.1.tags.0.value]: The value must be a string.');
+    }
+
+    public function test_import_upload_success()
+    {
+        $admin = $this->users->admin();
+        $this->actingAs($admin);
+        $data = [
+            'book' => [
+                'name' => 'My great book name',
+                'chapters' => [
+                    [
+                        'name' => 'my chapter',
+                        'pages' => [
+                            [
+                                'name' => 'my chapter page',
+                            ]
+                        ]
+                    ]
+                ],
+                'pages' => [
+                    [
+                        'name' => 'My page',
+                    ]
+                ],
+            ],
+        ];
+
+        $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData($data));
+
+        $this->assertDatabaseHas('imports', [
+            'name' => 'My great book name',
+            'type' => 'book',
+            'created_by' => $admin->id,
+        ]);
+
+        /** @var Import $import */
+        $import = Import::query()->latest()->first();
+        $resp->assertRedirect("/import/{$import->id}");
+        $this->assertFileExists(storage_path($import->path));
+        $this->assertActivityExists(ActivityType::IMPORT_CREATE);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_import_show_page()
+    {
+        $exportBook = new ZipExportBook();
+        $exportBook->name = 'My exported book';
+        $exportChapter = new ZipExportChapter();
+        $exportChapter->name = 'My exported chapter';
+        $exportPage = new ZipExportPage();
+        $exportPage->name = 'My exported page';
+        $exportBook->chapters = [$exportChapter];
+        $exportChapter->pages = [$exportPage];
+
+        $import = Import::factory()->create([
+            'name' => 'MySuperAdminImport',
+            'metadata' => json_encode($exportBook)
+        ]);
+
+        $resp = $this->asAdmin()->get("/import/{$import->id}");
+        $resp->assertOk();
+        $resp->assertSeeText('My exported book');
+        $resp->assertSeeText('My exported chapter');
+        $resp->assertSeeText('My exported page');
+    }
+
+    public function test_import_show_page_access_limited()
+    {
+        $user = $this->users->viewer();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->actingAs($user);
+
+        $this->get("/import/{$userImport->id}")->assertRedirect('/');
+        $this->get("/import/{$adminImport->id}")->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $this->get("/import/{$userImport->id}")->assertOk();
+        $this->get("/import/{$adminImport->id}")->assertStatus(404);
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $this->get("/import/{$userImport->id}")->assertOk();
+        $this->get("/import/{$adminImport->id}")->assertOk();
+    }
+
+    public function test_import_delete()
+    {
+        $this->asAdmin();
+        $this->runImportFromFile(ZipTestHelper::zipUploadFromData([
+            'book' => [
+                'name' => 'My great book name'
+            ],
+        ]));
+
+        /** @var Import $import */
+        $import = Import::query()->latest()->first();
+        $this->assertDatabaseHas('imports', [
+            'id' => $import->id,
+            'name' => 'My great book name'
+        ]);
+        $this->assertFileExists(storage_path($import->path));
+
+        $resp = $this->delete("/import/{$import->id}");
+
+        $resp->assertRedirect('/import');
+        $this->assertActivityExists(ActivityType::IMPORT_DELETE);
+        $this->assertDatabaseMissing('imports', [
+            'id' => $import->id,
+        ]);
+        $this->assertFileDoesNotExist(storage_path($import->path));
+    }
+
+    public function test_import_delete_access_limited()
+    {
+        $user = $this->users->viewer();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->actingAs($user);
+
+        $this->delete("/import/{$userImport->id}")->assertRedirect('/');
+        $this->delete("/import/{$adminImport->id}")->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $this->delete("/import/{$userImport->id}")->assertRedirect('/import');
+        $this->delete("/import/{$adminImport->id}")->assertStatus(404);
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $this->delete("/import/{$adminImport->id}")->assertRedirect('/import');
+    }
+
+    public function test_run_simple_success_scenario()
+    {
+        $import = ZipTestHelper::importFromData([], [
+            'book' => [
+                'name' => 'My imported book',
+                'pages' => [
+                    [
+                        'name' => 'My imported book page',
+                        'html' => '<p>Hello there from child page!</p>'
+                    ]
+                ],
+            ]
+        ]);
+
+        $resp = $this->asAdmin()->post("/import/{$import->id}");
+        $book = Book::query()->where('name', '=', 'My imported book')->latest()->first();
+        $resp->assertRedirect($book->getUrl());
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('My imported book page');
+        $resp->assertSee('Hello there from child page!');
+
+        $this->assertDatabaseMissing('imports', ['id' => $import->id]);
+        $this->assertFileDoesNotExist(storage_path($import->path));
+        $this->assertActivityExists(ActivityType::IMPORT_RUN, null, $import->logDescriptor());
+    }
+
+    public function test_import_run_access_limited()
+    {
+        $user = $this->users->editor();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->actingAs($user);
+
+        $this->post("/import/{$userImport->id}")->assertRedirect('/');
+        $this->post("/import/{$adminImport->id}")->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $this->post("/import/{$userImport->id}")->assertRedirect($userImport->getUrl()); // Getting validation response instead of access issue response
+        $this->post("/import/{$adminImport->id}")->assertStatus(404);
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $this->post("/import/{$adminImport->id}")->assertRedirect($adminImport->getUrl()); // Getting validation response instead of access issue response
+    }
+
+    public function test_run_revalidates_content()
+    {
+        $import = ZipTestHelper::importFromData([], [
+            'book' => [
+                'id' => 'abc',
+            ]
+        ]);
+
+        $resp = $this->asAdmin()->post("/import/{$import->id}");
+        $resp->assertRedirect($import->getUrl());
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('The name field is required.');
+        $resp->assertSeeText('The id must be an integer.');
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_run_checks_permissions_on_import()
+    {
+        $viewer = $this->users->viewer();
+        $this->permissions->grantUserRolePermissions($viewer, ['content-import']);
+        $import = ZipTestHelper::importFromData(['created_by' => $viewer->id], [
+            'book' => ['name' => 'My import book'],
+        ]);
+
+        $resp = $this->asViewer()->post("/import/{$import->id}");
+        $resp->assertRedirect($import->getUrl());
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('You are lacking the required permissions to create books.');
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_run_requires_parent_for_chapter_and_page_imports()
+    {
+        $book = $this->entities->book();
+        $pageImport = ZipTestHelper::importFromData([], [
+            'page' => ['name' => 'My page', 'html' => '<p>page test!</p>'],
+        ]);
+        $chapterImport = ZipTestHelper::importFromData([], [
+            'chapter' => ['name' => 'My chapter'],
+        ]);
+
+        $resp = $this->asAdmin()->post("/import/{$pageImport->id}");
+        $resp->assertRedirect($pageImport->getUrl());
+        $this->followRedirects($resp)->assertSee('The parent field is required.');
+
+        $resp = $this->asAdmin()->post("/import/{$pageImport->id}", ['parent' => "book:{$book->id}"]);
+        $resp->assertRedirectContains($book->getUrl());
+
+        $resp = $this->asAdmin()->post("/import/{$chapterImport->id}");
+        $resp->assertRedirect($chapterImport->getUrl());
+        $this->followRedirects($resp)->assertSee('The parent field is required.');
+
+        $resp = $this->asAdmin()->post("/import/{$chapterImport->id}", ['parent' => "book:{$book->id}"]);
+        $resp->assertRedirectContains($book->getUrl());
+    }
+
+    public function test_run_validates_correct_parent_type()
+    {
+        $chapter = $this->entities->chapter();
+        $import = ZipTestHelper::importFromData([], [
+            'chapter' => ['name' => 'My chapter'],
+        ]);
+
+        $resp = $this->asAdmin()->post("/import/{$import->id}", ['parent' => "chapter:{$chapter->id}"]);
+        $resp->assertRedirect($import->getUrl());
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('Parent book required for chapter import.');
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    protected function runImportFromFile(UploadedFile $file): TestResponse
+    {
+        return $this->call('POST', '/import', [], [], ['file' => $file]);
+    }
+}
diff --git a/tests/Exports/ZipResultData.php b/tests/Exports/ZipResultData.php
new file mode 100644 (file)
index 0000000..7725004
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace Tests\Exports;
+
+class ZipResultData
+{
+    public function __construct(
+        public string $zipPath,
+        public string $extractedDirPath,
+        public array $data,
+    ) {
+    }
+
+    /**
+     * Build a path to a location the extracted content, using the given relative $path.
+     */
+    public function extractPath(string $path): string
+    {
+        $relPath = implode(DIRECTORY_SEPARATOR, explode('/', $path));
+        return $this->extractedDirPath . DIRECTORY_SEPARATOR . ltrim($relPath, DIRECTORY_SEPARATOR);
+    }
+}
diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php
new file mode 100644 (file)
index 0000000..d830d8e
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Exports\Import;
+use Illuminate\Http\UploadedFile;
+use ZipArchive;
+
+class ZipTestHelper
+{
+    public static function importFromData(array $importData, array $zipData, array $files = []): Import
+    {
+        if (isset($zipData['book'])) {
+            $importData['type'] = 'book';
+        } else if (isset($zipData['chapter'])) {
+            $importData['type'] = 'chapter';
+        } else if (isset($zipData['page'])) {
+            $importData['type'] = 'page';
+        }
+
+        $import = Import::factory()->create($importData);
+        $zip = static::zipUploadFromData($zipData, $files);
+        $targetPath = storage_path($import->path);
+        $targetDir = dirname($targetPath);
+
+        if (!file_exists($targetDir)) {
+            mkdir($targetDir);
+        }
+
+        rename($zip->getRealPath(), $targetPath);
+
+        return $import;
+    }
+
+    public static function deleteZipForImport(Import $import): void
+    {
+        $path = storage_path($import->path);
+        if (file_exists($path)) {
+            unlink($path);
+        }
+    }
+
+    public static function zipUploadFromData(array $data, array $files = []): UploadedFile
+    {
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
+
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::CREATE);
+        $zip->addFromString('data.json', json_encode($data));
+
+        foreach ($files as $name => $file) {
+            $zip->addFile($file, "files/$name");
+        }
+
+        $zip->close();
+
+        return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true);
+    }
+}
index 2e1a7b3395ac07826f97204b907b1155aa685b08..de448d93a4c26139d96b2f76d1ff873482e2c488 100644 (file)
@@ -404,8 +404,8 @@ class AttachmentTest extends TestCase
             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-2010']);
             $resp->assertStreamedContent($content);
             $resp->assertHeader('Content-Length', '2005');
-            $resp->assertHeaderMissing('Content-Range');
-            $resp->assertStatus(200);
+            $resp->assertHeader('Content-Range', 'bytes 0-2004/2005');
+            $resp->assertStatus(206);
 
             // Range start before end
             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=50-10']);
@@ -413,6 +413,13 @@ class AttachmentTest extends TestCase
             $resp->assertHeader('Content-Length', '2005');
             $resp->assertHeader('Content-Range', 'bytes */2005');
             $resp->assertStatus(416);
+
+            // Full range request
+            $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-']);
+            $resp->assertStreamedContent($content);
+            $resp->assertHeader('Content-Length', '2005');
+            $resp->assertHeader('Content-Range', 'bytes 0-2004/2005');
+            $resp->assertStatus(206);
         }
 
         $this->files->deleteAllAttachmentFiles();