diff --git a/.github/translators.txt b/.github/translators.txt index ac5fa766f..dfaa8d621 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -493,3 +493,10 @@ Angel Pandey (angel-pandey) :: Nepali Supriya Shrestha (supriyashrestha) :: Nepali gprabhat :: Nepali CellCat :: Chinese Simplified +Al Desrahim (aldesrahim) :: Indonesian +ahmad abbaspour (deshneh.dar.diss) :: Persian +Erjon K. (ekr) :: Albanian +LiZerui (iamzrli) :: Chinese Traditional +Ticker (ticker.com) :: Hebrew +CrazyComputer :: Chinese Simplified +Firr (FirrV) :: Russian diff --git a/app/Activity/Models/Tag.php b/app/Activity/Models/Tag.php index 0af0a65ac..0e7c68a27 100644 --- a/app/Activity/Models/Tag.php +++ b/app/Activity/Models/Tag.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; * @property int $id * @property string $name * @property string $value + * @property int $entity_id + * @property string $entity_type * @property int $order */ class Tag extends Model diff --git a/app/Activity/Tools/TagClassGenerator.php b/app/Activity/Tools/TagClassGenerator.php index 1a1bd16c8..5bcb44113 100644 --- a/app/Activity/Tools/TagClassGenerator.php +++ b/app/Activity/Tools/TagClassGenerator.php @@ -3,17 +3,15 @@ namespace BookStack\Activity\Tools; use BookStack\Activity\Models\Tag; +use BookStack\Entities\Models\BookChild; +use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; class TagClassGenerator { - protected array $tags; - - /** - * @param Tag[] $tags - */ - public function __construct(array $tags) - { - $this->tags = $tags; + public function __construct( + protected Entity $entity + ) { } /** @@ -22,14 +20,23 @@ class TagClassGenerator public function generate(): array { $classes = []; + $tags = $this->entity->tags->all(); - foreach ($this->tags as $tag) { - $name = $this->normalizeTagClassString($tag->name); - $value = $this->normalizeTagClassString($tag->value); - $classes[] = 'tag-name-' . $name; - if ($value) { - $classes[] = 'tag-value-' . $value; - $classes[] = 'tag-pair-' . $name . '-' . $value; + foreach ($tags as $tag) { + array_push($classes, ...$this->generateClassesForTag($tag)); + } + + if ($this->entity instanceof BookChild && userCan('view', $this->entity->book)) { + $bookTags = $this->entity->book->tags; + foreach ($bookTags as $bookTag) { + array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-')); + } + } + + if ($this->entity instanceof Page && $this->entity->chapter && userCan('view', $this->entity->chapter)) { + $chapterTags = $this->entity->chapter->tags; + foreach ($chapterTags as $chapterTag) { + array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-')); } } @@ -41,6 +48,22 @@ class TagClassGenerator return implode(' ', $this->generate()); } + /** + * @return string[] + */ + protected function generateClassesForTag(Tag $tag, string $prefix = ''): array + { + $classes = []; + $name = $this->normalizeTagClassString($tag->name); + $value = $this->normalizeTagClassString($tag->value); + $classes[] = "{$prefix}tag-name-{$name}"; + if ($value) { + $classes[] = "{$prefix}tag-value-{$value}"; + $classes[] = "{$prefix}tag-pair-{$name}-{$value}"; + } + return $classes; + } + protected function normalizeTagClassString(string $value): string { $value = str_replace(' ', '', strtolower($value)); diff --git a/app/Config/mail.php b/app/Config/mail.php index 038864f8c..7256ce884 100644 --- a/app/Config/mail.php +++ b/app/Config/mail.php @@ -11,6 +11,7 @@ // Configured mail encryption method. // STARTTLS should still be attempted, but tls/ssl forces TLS usage. $mailEncryption = env('MAIL_ENCRYPTION', null); +$mailPort = intval(env('MAIL_PORT', 587)); return [ @@ -33,13 +34,13 @@ return [ 'transport' => 'smtp', 'scheme' => null, 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), - 'port' => env('MAIL_PORT', 587), + 'port' => $mailPort, 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), 'verify_peer' => env('MAIL_VERIFY_SSL', true), 'timeout' => null, 'local_domain' => null, - 'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'), + 'require_tls' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl' || $mailPort === 465), ], 'sendmail' => [ diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index b1685081a..5d3d67f64 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -18,6 +18,7 @@ use BookStack\Exceptions\NotFoundException; use BookStack\Facades\Activity; use BookStack\Http\Controller; use BookStack\References\ReferenceFetcher; +use BookStack\Util\DatabaseTransaction; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -263,7 +264,9 @@ class BookController extends Controller $this->checkPermission('bookshelf-create-all'); $this->checkPermission('book-create-all'); - $shelf = $transformer->transformBookToShelf($book); + $shelf = (new DatabaseTransaction(function () use ($book, $transformer) { + return $transformer->transformBookToShelf($book); + }))->run(); return redirect($shelf->getUrl()); } diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php index 430654330..8ac0c7a60 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -9,12 +9,11 @@ use BookStack\Entities\Repos\ChapterRepo; use BookStack\Exceptions\PermissionsException; use BookStack\Http\ApiController; use Exception; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Http\Request; class ChapterApiController extends ApiController { - protected $rules = [ + protected array $rules = [ 'create' => [ 'book_id' => ['required', 'integer'], 'name' => ['required', 'string', 'max:255'], diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index 4274589e2..677745500 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -18,6 +18,7 @@ use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\PermissionsException; use BookStack\Http\Controller; use BookStack\References\ReferenceFetcher; +use BookStack\Util\DatabaseTransaction; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Throwable; @@ -269,7 +270,9 @@ class ChapterController extends Controller $this->checkOwnablePermission('chapter-delete', $chapter); $this->checkPermission('book-create-all'); - $book = $transformer->transformChapterToBook($chapter); + $book = (new DatabaseTransaction(function () use ($chapter, $transformer) { + return $transformer->transformChapterToBook($chapter); + }))->run(); return redirect($book->getUrl()); } diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 40598e209..8fcba3dc6 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -12,7 +12,7 @@ use Illuminate\Http\Request; class PageApiController extends ApiController { - protected $rules = [ + protected array $rules = [ 'create' => [ 'book_id' => ['required_without:chapter_id', 'integer'], 'chapter_id' => ['required_without:book_id', 'integer'], diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 151d5b055..ac5a44e67 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -77,7 +77,6 @@ class BaseRepo $entity->touch(); } - $entity->rebuildPermissions(); $entity->indexForSearch(); $this->referenceStore->updateForEntity($entity); @@ -139,7 +138,7 @@ class BaseRepo /** * Sort the parent of the given entity, if any auto sort actions are set for it. - * Typical ran during create/update/insert events. + * Typically ran during create/update/insert events. */ public function sortParent(Entity $entity): void { diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 92e6a81c3..6d28d5d6a 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -10,6 +10,7 @@ use BookStack\Exceptions\ImageUploadException; use BookStack\Facades\Activity; use BookStack\Sorting\SortRule; use BookStack\Uploads\ImageRepo; +use BookStack\Util\DatabaseTransaction; use Exception; use Illuminate\Http\UploadedFile; @@ -28,19 +29,22 @@ class BookRepo */ public function create(array $input): Book { - $book = new Book(); - $this->baseRepo->create($book, $input); - $this->baseRepo->updateCoverImage($book, $input['image'] ?? null); - $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null)); - Activity::add(ActivityType::BOOK_CREATE, $book); + return (new DatabaseTransaction(function () use ($input) { + $book = new Book(); - $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); - if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { - $book->sort_rule_id = $defaultBookSortSetting; - $book->save(); - } + $this->baseRepo->create($book, $input); + $this->baseRepo->updateCoverImage($book, $input['image'] ?? null); + $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null)); + Activity::add(ActivityType::BOOK_CREATE, $book); - return $book; + $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); + if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { + $book->sort_rule_id = $defaultBookSortSetting; + $book->save(); + } + + return $book; + }))->run(); } /** diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index a00349ef1..8e60f58c4 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -7,6 +7,7 @@ use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Tools\TrashCan; use BookStack\Facades\Activity; +use BookStack\Util\DatabaseTransaction; use Exception; class BookshelfRepo @@ -23,13 +24,14 @@ class BookshelfRepo */ public function create(array $input, array $bookIds): Bookshelf { - $shelf = new Bookshelf(); - $this->baseRepo->create($shelf, $input); - $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null); - $this->updateBooks($shelf, $bookIds); - Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); - - return $shelf; + return (new DatabaseTransaction(function () use ($input, $bookIds) { + $shelf = new Bookshelf(); + $this->baseRepo->create($shelf, $input); + $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null); + $this->updateBooks($shelf, $bookIds); + Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); + return $shelf; + }))->run(); } /** diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index fdf2de4e2..6503e63cf 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -11,6 +11,7 @@ use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\PermissionsException; use BookStack\Facades\Activity; +use BookStack\Util\DatabaseTransaction; use Exception; class ChapterRepo @@ -27,16 +28,18 @@ class ChapterRepo */ public function create(array $input, Book $parentBook): Chapter { - $chapter = new Chapter(); - $chapter->book_id = $parentBook->id; - $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; - $this->baseRepo->create($chapter, $input); - $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null)); - Activity::add(ActivityType::CHAPTER_CREATE, $chapter); + return (new DatabaseTransaction(function () use ($input, $parentBook) { + $chapter = new Chapter(); + $chapter->book_id = $parentBook->id; + $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; + $this->baseRepo->create($chapter, $input); + $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null)); + Activity::add(ActivityType::CHAPTER_CREATE, $chapter); - $this->baseRepo->sortParent($chapter); + $this->baseRepo->sortParent($chapter); - return $chapter; + return $chapter; + }))->run(); } /** @@ -88,12 +91,14 @@ class ChapterRepo throw new PermissionsException('User does not have permission to create a chapter within the chosen book'); } - $chapter->changeBook($parent->id); - $chapter->rebuildPermissions(); - Activity::add(ActivityType::CHAPTER_MOVE, $chapter); + return (new DatabaseTransaction(function () use ($chapter, $parent) { + $chapter->changeBook($parent->id); + $chapter->rebuildPermissions(); + Activity::add(ActivityType::CHAPTER_MOVE, $chapter); - $this->baseRepo->sortParent($chapter); + $this->baseRepo->sortParent($chapter); - return $parent; + return $parent; + }))->run(); } } diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index c3be6d826..63e8b8370 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -18,6 +18,7 @@ use BookStack\Exceptions\PermissionsException; use BookStack\Facades\Activity; use BookStack\References\ReferenceStore; use BookStack\References\ReferenceUpdater; +use BookStack\Util\DatabaseTransaction; use Exception; class PageRepo @@ -61,8 +62,10 @@ class PageRepo ]); } - $page->save(); - $page->refresh()->rebuildPermissions(); + (new DatabaseTransaction(function () use ($page) { + $page->save(); + $page->refresh()->rebuildPermissions(); + }))->run(); return $page; } @@ -72,26 +75,29 @@ class PageRepo */ public function publishDraft(Page $draft, array $input): Page { - $draft->draft = false; - $draft->revision_count = 1; - $draft->priority = $this->getNewPriority($draft); - $this->updateTemplateStatusAndContentFromInput($draft, $input); - $this->baseRepo->update($draft, $input); + return (new DatabaseTransaction(function () use ($draft, $input) { + $draft->draft = false; + $draft->revision_count = 1; + $draft->priority = $this->getNewPriority($draft); + $this->updateTemplateStatusAndContentFromInput($draft, $input); + $this->baseRepo->update($draft, $input); + $draft->rebuildPermissions(); - $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision'); - $this->revisionRepo->storeNewForPage($draft, $summary); - $draft->refresh(); + $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision'); + $this->revisionRepo->storeNewForPage($draft, $summary); + $draft->refresh(); - Activity::add(ActivityType::PAGE_CREATE, $draft); - $this->baseRepo->sortParent($draft); + Activity::add(ActivityType::PAGE_CREATE, $draft); + $this->baseRepo->sortParent($draft); - return $draft; + return $draft; + }))->run(); } /** * 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. + * (Search index and reference regen) without performing an official update. */ public function setContentFromInput(Page $page, array $input): void { @@ -116,7 +122,7 @@ class PageRepo $page->revision_count++; $page->save(); - // Remove all update drafts for this user & page. + // Remove all update drafts for this user and page. $this->revisionRepo->deleteDraftsForCurrentUser($page); // Save a revision after updating @@ -269,16 +275,18 @@ class PageRepo throw new PermissionsException('User does not have permission to create a page within the new parent'); } - $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; - $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; - $page->changeBook($newBookId); - $page->rebuildPermissions(); + return (new DatabaseTransaction(function () use ($page, $parent) { + $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; + $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; + $page->changeBook($newBookId); + $page->rebuildPermissions(); - Activity::add(ActivityType::PAGE_MOVE, $page); + Activity::add(ActivityType::PAGE_MOVE, $page); - $this->baseRepo->sortParent($page); + $this->baseRepo->sortParent($page); - return $parent; + return $parent; + }))->run(); } /** diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php index cd6c548fe..b0d8880f4 100644 --- a/app/Entities/Tools/HierarchyTransformer.php +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -13,17 +13,12 @@ use BookStack\Facades\Activity; class HierarchyTransformer { - protected BookRepo $bookRepo; - protected BookshelfRepo $shelfRepo; - protected Cloner $cloner; - protected TrashCan $trashCan; - - public function __construct(BookRepo $bookRepo, BookshelfRepo $shelfRepo, Cloner $cloner, TrashCan $trashCan) - { - $this->bookRepo = $bookRepo; - $this->shelfRepo = $shelfRepo; - $this->cloner = $cloner; - $this->trashCan = $trashCan; + public function __construct( + protected BookRepo $bookRepo, + protected BookshelfRepo $shelfRepo, + protected Cloner $cloner, + protected TrashCan $trashCan + ) { } /** diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index 39c982cdc..5e8a93719 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -15,6 +15,7 @@ use BookStack\Exceptions\NotifyException; use BookStack\Facades\Activity; use BookStack\Uploads\AttachmentService; use BookStack\Uploads\ImageService; +use BookStack\Util\DatabaseTransaction; use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -357,25 +358,26 @@ class TrashCan /** * Destroy the given entity. + * Returns the number of total entities destroyed in the operation. * * @throws Exception */ public function destroyEntity(Entity $entity): int { - if ($entity instanceof Page) { - return $this->destroyPage($entity); - } - if ($entity instanceof Chapter) { - return $this->destroyChapter($entity); - } - if ($entity instanceof Book) { - return $this->destroyBook($entity); - } - if ($entity instanceof Bookshelf) { - return $this->destroyShelf($entity); - } + $result = (new DatabaseTransaction(function () use ($entity) { + if ($entity instanceof Page) { + return $this->destroyPage($entity); + } else if ($entity instanceof Chapter) { + return $this->destroyChapter($entity); + } else if ($entity instanceof Book) { + return $this->destroyBook($entity); + } else if ($entity instanceof Bookshelf) { + return $this->destroyShelf($entity); + } + return null; + }))->run(); - return 0; + return $result ?? 0; } /** diff --git a/app/Exports/Controllers/BookExportApiController.php b/app/Exports/Controllers/BookExportApiController.php index 164946b0c..87f1d7eef 100644 --- a/app/Exports/Controllers/BookExportApiController.php +++ b/app/Exports/Controllers/BookExportApiController.php @@ -4,6 +4,7 @@ namespace BookStack\Exports\Controllers; use BookStack\Entities\Queries\BookQueries; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; use Throwable; @@ -63,4 +64,15 @@ class BookExportApiController extends ApiController return $this->download()->directly($markdown, $book->slug . '.md'); } + + /** + * Export a book as a contained ZIP export file. + */ + public function exportZip(int $id, ZipExportBuilder $builder) + { + $book = $this->queries->findVisibleByIdOrFail($id); + $zip = $builder->buildForBook($book); + + return $this->download()->streamedFileDirectly($zip, $book->slug . '.zip', true); + } } diff --git a/app/Exports/Controllers/ChapterExportApiController.php b/app/Exports/Controllers/ChapterExportApiController.php index 9914e2b7f..bccd414af 100644 --- a/app/Exports/Controllers/ChapterExportApiController.php +++ b/app/Exports/Controllers/ChapterExportApiController.php @@ -4,6 +4,7 @@ namespace BookStack\Exports\Controllers; use BookStack\Entities\Queries\ChapterQueries; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; use Throwable; @@ -63,4 +64,15 @@ class ChapterExportApiController extends ApiController return $this->download()->directly($markdown, $chapter->slug . '.md'); } + + /** + * Export a chapter as a contained ZIP file. + */ + public function exportZip(int $id, ZipExportBuilder $builder) + { + $chapter = $this->queries->findVisibleByIdOrFail($id); + $zip = $builder->buildForChapter($chapter); + + return $this->download()->streamedFileDirectly($zip, $chapter->slug . '.zip', true); + } } diff --git a/app/Exports/Controllers/ImportApiController.php b/app/Exports/Controllers/ImportApiController.php new file mode 100644 index 000000000..cac155c7c --- /dev/null +++ b/app/Exports/Controllers/ImportApiController.php @@ -0,0 +1,144 @@ +middleware('can:content-import'); + } + + /** + * List existing ZIP imports visible to the user. + * Requires permission to import content. + */ + public function list(): JsonResponse + { + $query = $this->imports->queryVisible(); + + return $this->apiListingResponse($query, [ + 'id', 'name', 'size', 'type', 'created_by', 'created_at', 'updated_at' + ]); + } + + /** + * Start a new import from a ZIP file. + * This does not actually run the import since that is performed via the "run" endpoint. + * This uploads, validates and stores the ZIP file so it's ready to be imported. + * + * This "file" parameter must be a BookStack-compatible ZIP file, and this must be + * sent via a 'multipart/form-data' type request. + * + * Requires permission to import content. + */ + public function create(Request $request): JsonResponse + { + $this->validate($request, $this->rules()['create']); + + $file = $request->file('file'); + + try { + $import = $this->imports->storeFromUpload($file); + } catch (ZipValidationException $exception) { + $message = "ZIP upload failed with the following validation errors: \n" . $this->formatErrors($exception->errors); + return $this->jsonError($message, 422); + } + + return response()->json($import); + } + + /** + * Read details of a pending ZIP import. + * The "details" property contains high-level metadata regarding the ZIP import content, + * and the structure of this will change depending on import "type". + * Requires permission to import content. + */ + public function read(int $id): JsonResponse + { + $import = $this->imports->findVisible($id); + + $import->setAttribute('details', $import->decodeMetadata()); + + return response()->json($import); + } + + /** + * Run the import process for an uploaded ZIP import. + * The "parent_id" and "parent_type" parameters are required when the import type is "chapter" or "page". + * On success, this endpoint returns the imported item. + * Requires permission to import content. + */ + public function run(int $id, Request $request): JsonResponse + { + $import = $this->imports->findVisible($id); + $parent = null; + $rules = $this->rules()['run']; + + if ($import->type === 'page' || $import->type === 'chapter') { + $rules['parent_type'][] = 'required'; + $rules['parent_id'][] = 'required'; + $data = $this->validate($request, $rules); + $parent = "{$data['parent_type']}:{$data['parent_id']}"; + } + + try { + $entity = $this->imports->runImport($import, $parent); + } catch (ZipImportException $exception) { + $message = "ZIP import failed with the following errors: \n" . $this->formatErrors($exception->errors); + return $this->jsonError($message); + } + + return response()->json($entity->withoutRelations()); + } + + /** + * Delete a pending ZIP import from the system. + * Requires permission to import content. + */ + public function delete(int $id): Response + { + $import = $this->imports->findVisible($id); + $this->imports->deleteImport($import); + + return response('', 204); + } + + protected function rules(): array + { + return [ + 'create' => [ + 'file' => ['required', ...AttachmentService::getFileValidationRules()], + ], + 'run' => [ + 'parent_type' => ['string', 'in:book,chapter'], + 'parent_id' => ['int'], + ], + ]; + } + + protected function formatErrors(array $errors): string + { + $parts = []; + foreach ($errors as $key => $error) { + if (is_string($key)) { + $parts[] = "[{$key}] {$error}"; + } else { + $parts[] = $error; + } + } + return implode("\n", $parts); + } +} diff --git a/app/Exports/Controllers/PageExportApiController.php b/app/Exports/Controllers/PageExportApiController.php index c6e20b615..73af01afa 100644 --- a/app/Exports/Controllers/PageExportApiController.php +++ b/app/Exports/Controllers/PageExportApiController.php @@ -4,6 +4,7 @@ namespace BookStack\Exports\Controllers; use BookStack\Entities\Queries\PageQueries; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; use Throwable; @@ -63,4 +64,15 @@ class PageExportApiController extends ApiController return $this->download()->directly($markdown, $page->slug . '.md'); } + + /** + * Export a page as a contained ZIP file. + */ + public function exportZip(int $id, ZipExportBuilder $builder) + { + $page = $this->queries->findVisibleByIdOrFail($id); + $zip = $builder->buildForPage($page); + + return $this->download()->streamedFileDirectly($zip, $page->slug . '.zip', true); + } } diff --git a/app/Exports/Import.php b/app/Exports/Import.php index 9c1771c46..ca4f52981 100644 --- a/app/Exports/Import.php +++ b/app/Exports/Import.php @@ -28,6 +28,8 @@ class Import extends Model implements Loggable { use HasFactory; + protected $hidden = ['metadata']; + public function getSizeString(): string { $mb = round($this->size / 1000000, 2); diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index f72386c47..e030a88d2 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -17,6 +17,7 @@ use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Facades\Activity; use BookStack\Uploads\FileStorage; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -34,6 +35,11 @@ class ImportRepo * @return Collection */ public function getVisibleImports(): Collection + { + return $this->queryVisible()->get(); + } + + public function queryVisible(): Builder { $query = Import::query(); @@ -41,7 +47,7 @@ class ImportRepo $query->where('created_by', user()->id); } - return $query->get(); + return $query; } public function findVisible(int $id): Import diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index c0dbe2fca..1a92afa33 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -8,7 +8,7 @@ use Illuminate\Http\JsonResponse; abstract class ApiController extends Controller { - protected $rules = []; + protected array $rules = []; /** * Provide a paginated listing JSON response in a standard format diff --git a/app/Permissions/ContentPermissionApiController.php b/app/Permissions/ContentPermissionApiController.php index 23b75db35..bddbc2c7d 100644 --- a/app/Permissions/ContentPermissionApiController.php +++ b/app/Permissions/ContentPermissionApiController.php @@ -16,7 +16,7 @@ class ContentPermissionApiController extends ApiController ) { } - protected $rules = [ + protected array $rules = [ 'update' => [ 'owner_id' => ['int'], diff --git a/app/Permissions/JointPermissionBuilder.php b/app/Permissions/JointPermissionBuilder.php index c2922cdc9..56b22ad16 100644 --- a/app/Permissions/JointPermissionBuilder.php +++ b/app/Permissions/JointPermissionBuilder.php @@ -29,7 +29,7 @@ class JointPermissionBuilder /** * Re-generate all entity permission from scratch. */ - public function rebuildForAll() + public function rebuildForAll(): void { JointPermission::query()->truncate(); @@ -51,7 +51,7 @@ class JointPermissionBuilder /** * Rebuild the entity jointPermissions for a particular entity. */ - public function rebuildForEntity(Entity $entity) + public function rebuildForEntity(Entity $entity): void { $entities = [$entity]; if ($entity instanceof Book) { @@ -119,7 +119,7 @@ class JointPermissionBuilder /** * Build joint permissions for the given book and role combinations. */ - protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false) + protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false): void { $entities = clone $books; @@ -143,7 +143,7 @@ class JointPermissionBuilder /** * Rebuild the entity jointPermissions for a collection of entities. */ - protected function buildJointPermissionsForEntities(array $entities) + protected function buildJointPermissionsForEntities(array $entities): void { $roles = Role::query()->get()->values()->all(); $this->deleteManyJointPermissionsForEntities($entities); @@ -155,21 +155,19 @@ class JointPermissionBuilder * * @param Entity[] $entities */ - protected function deleteManyJointPermissionsForEntities(array $entities) + protected function deleteManyJointPermissionsForEntities(array $entities): void { $simpleEntities = $this->entitiesToSimpleEntities($entities); $idsByType = $this->entitiesToTypeIdMap($simpleEntities); - DB::transaction(function () use ($idsByType) { - foreach ($idsByType as $type => $ids) { - foreach (array_chunk($ids, 1000) as $idChunk) { - DB::table('joint_permissions') - ->where('entity_type', '=', $type) - ->whereIn('entity_id', $idChunk) - ->delete(); - } + foreach ($idsByType as $type => $ids) { + foreach (array_chunk($ids, 1000) as $idChunk) { + DB::table('joint_permissions') + ->where('entity_type', '=', $type) + ->whereIn('entity_id', $idChunk) + ->delete(); } - }); + } } /** @@ -195,7 +193,7 @@ class JointPermissionBuilder * @param Entity[] $originalEntities * @param Role[] $roles */ - protected function createManyJointPermissions(array $originalEntities, array $roles) + protected function createManyJointPermissions(array $originalEntities, array $roles): void { $entities = $this->entitiesToSimpleEntities($originalEntities); $jointPermissions = []; @@ -225,11 +223,9 @@ class JointPermissionBuilder } } - DB::transaction(function () use ($jointPermissions) { - foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) { - DB::table('joint_permissions')->insert($jointPermissionChunk); - } - }); + foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) { + DB::table('joint_permissions')->insert($jointPermissionChunk); + } } /** diff --git a/app/Permissions/PermissionsController.php b/app/Permissions/PermissionsController.php index 5d2035870..9dcfe242e 100644 --- a/app/Permissions/PermissionsController.php +++ b/app/Permissions/PermissionsController.php @@ -7,6 +7,7 @@ use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Http\Controller; use BookStack\Permissions\Models\EntityPermission; use BookStack\Users\Models\Role; +use BookStack\Util\DatabaseTransaction; use Illuminate\Http\Request; class PermissionsController extends Controller @@ -40,7 +41,9 @@ class PermissionsController extends Controller $page = $this->queries->pages->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission('restrictions-manage', $page); - $this->permissionsUpdater->updateFromPermissionsForm($page, $request); + (new DatabaseTransaction(function () use ($page, $request) { + $this->permissionsUpdater->updateFromPermissionsForm($page, $request); + }))->run(); $this->showSuccessNotification(trans('entities.pages_permissions_success')); @@ -70,7 +73,9 @@ class PermissionsController extends Controller $chapter = $this->queries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->checkOwnablePermission('restrictions-manage', $chapter); - $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request); + (new DatabaseTransaction(function () use ($chapter, $request) { + $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request); + }))->run(); $this->showSuccessNotification(trans('entities.chapters_permissions_success')); @@ -100,7 +105,9 @@ class PermissionsController extends Controller $book = $this->queries->books->findVisibleBySlugOrFail($slug); $this->checkOwnablePermission('restrictions-manage', $book); - $this->permissionsUpdater->updateFromPermissionsForm($book, $request); + (new DatabaseTransaction(function () use ($book, $request) { + $this->permissionsUpdater->updateFromPermissionsForm($book, $request); + }))->run(); $this->showSuccessNotification(trans('entities.books_permissions_updated')); @@ -130,7 +137,9 @@ class PermissionsController extends Controller $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug); $this->checkOwnablePermission('restrictions-manage', $shelf); - $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request); + (new DatabaseTransaction(function () use ($shelf, $request) { + $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request); + }))->run(); $this->showSuccessNotification(trans('entities.shelves_permissions_updated')); @@ -145,7 +154,10 @@ class PermissionsController extends Controller $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug); $this->checkOwnablePermission('restrictions-manage', $shelf); - $updateCount = $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf); + $updateCount = (new DatabaseTransaction(function () use ($shelf) { + return $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf); + }))->run(); + $this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount])); return redirect($shelf->getUrl()); diff --git a/app/Permissions/PermissionsRepo.php b/app/Permissions/PermissionsRepo.php index b41612968..6ced7b751 100644 --- a/app/Permissions/PermissionsRepo.php +++ b/app/Permissions/PermissionsRepo.php @@ -7,6 +7,7 @@ use BookStack\Exceptions\PermissionsException; use BookStack\Facades\Activity; use BookStack\Permissions\Models\RolePermission; use BookStack\Users\Models\Role; +use BookStack\Util\DatabaseTransaction; use Exception; use Illuminate\Database\Eloquent\Collection; @@ -48,38 +49,42 @@ class PermissionsRepo */ public function saveNewRole(array $roleData): Role { - $role = new Role($roleData); - $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false); - $role->save(); + return (new DatabaseTransaction(function () use ($roleData) { + $role = new Role($roleData); + $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false); + $role->save(); - $permissions = $roleData['permissions'] ?? []; - $this->assignRolePermissions($role, $permissions); - $this->permissionBuilder->rebuildForRole($role); + $permissions = $roleData['permissions'] ?? []; + $this->assignRolePermissions($role, $permissions); + $this->permissionBuilder->rebuildForRole($role); - Activity::add(ActivityType::ROLE_CREATE, $role); + Activity::add(ActivityType::ROLE_CREATE, $role); - return $role; + return $role; + }))->run(); } /** * Updates an existing role. - * Ensures Admin system role always have core permissions. + * Ensures the Admin system role always has core permissions. */ public function updateRole($roleId, array $roleData): Role { $role = $this->getRoleById($roleId); - if (isset($roleData['permissions'])) { - $this->assignRolePermissions($role, $roleData['permissions']); - } + return (new DatabaseTransaction(function () use ($role, $roleData) { + if (isset($roleData['permissions'])) { + $this->assignRolePermissions($role, $roleData['permissions']); + } - $role->fill($roleData); - $role->save(); - $this->permissionBuilder->rebuildForRole($role); + $role->fill($roleData); + $role->save(); + $this->permissionBuilder->rebuildForRole($role); - Activity::add(ActivityType::ROLE_UPDATE, $role); + Activity::add(ActivityType::ROLE_UPDATE, $role); - return $role; + return $role; + }))->run(); } /** @@ -114,7 +119,7 @@ class PermissionsRepo /** * Delete a role from the system. * Check it's not an admin role or set as default before deleting. - * If a migration Role ID is specified the users assign to the current role + * If a migration Role ID is specified, the users assigned to the current role * will be added to the role of the specified id. * * @throws PermissionsException @@ -131,17 +136,19 @@ class PermissionsRepo throw new PermissionsException(trans('errors.role_registration_default_cannot_delete')); } - if ($migrateRoleId !== 0) { - $newRole = Role::query()->find($migrateRoleId); - if ($newRole) { - $users = $role->users()->pluck('id')->toArray(); - $newRole->users()->sync($users); + (new DatabaseTransaction(function () use ($migrateRoleId, $role) { + if ($migrateRoleId !== 0) { + $newRole = Role::query()->find($migrateRoleId); + if ($newRole) { + $users = $role->users()->pluck('id')->toArray(); + $newRole->users()->sync($users); + } } - } - $role->entityPermissions()->delete(); - $role->jointPermissions()->delete(); - Activity::add(ActivityType::ROLE_DELETE, $role); - $role->delete(); + $role->entityPermissions()->delete(); + $role->jointPermissions()->delete(); + Activity::add(ActivityType::ROLE_DELETE, $role); + $role->delete(); + }))->run(); } } diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index 79cd8cfab..cd4a14a39 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -9,7 +9,7 @@ use Illuminate\Http\Request; class SearchApiController extends ApiController { - protected $rules = [ + protected array $rules = [ 'all' => [ 'query' => ['required'], 'page' => ['integer', 'min:1'], diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php index 479d19724..d70d0e656 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -7,6 +7,7 @@ use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Tools\BookContents; use BookStack\Facades\Activity; use BookStack\Http\Controller; +use BookStack\Util\DatabaseTransaction; use Illuminate\Http\Request; class BookSortController extends Controller @@ -55,16 +56,18 @@ class BookSortController extends Controller // Sort via map if ($request->filled('sort-tree')) { - $sortMap = BookSortMap::fromJson($request->get('sort-tree')); - $booksInvolved = $sorter->sortUsingMap($sortMap); + (new DatabaseTransaction(function () use ($book, $request, $sorter, &$loggedActivityForBook) { + $sortMap = BookSortMap::fromJson($request->get('sort-tree')); + $booksInvolved = $sorter->sortUsingMap($sortMap); - // Rebuild permissions and add activity for involved books. - foreach ($booksInvolved as $bookInvolved) { - Activity::add(ActivityType::BOOK_SORT, $bookInvolved); - if ($bookInvolved->id === $book->id) { - $loggedActivityForBook = true; + // Add activity for involved books. + foreach ($booksInvolved as $bookInvolved) { + Activity::add(ActivityType::BOOK_SORT, $bookInvolved); + if ($bookInvolved->id === $book->id) { + $loggedActivityForBook = true; + } } - } + }))->run(); } if ($request->filled('auto-sort')) { diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index 6710f070a..cf41a6a94 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -2,7 +2,6 @@ namespace BookStack\Sorting; -use BookStack\App\Model; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Chapter; diff --git a/app/Users/Controllers/RoleApiController.php b/app/Users/Controllers/RoleApiController.php index 2e96602fa..2f3638cd3 100644 --- a/app/Users/Controllers/RoleApiController.php +++ b/app/Users/Controllers/RoleApiController.php @@ -16,7 +16,7 @@ class RoleApiController extends ApiController 'display_name', 'description', 'mfa_enforced', 'external_auth_id', 'created_at', 'updated_at', ]; - protected $rules = [ + protected array $rules = [ 'create' => [ 'display_name' => ['required', 'string', 'min:3', 'max:180'], 'description' => ['string', 'max:180'], diff --git a/app/Util/DatabaseTransaction.php b/app/Util/DatabaseTransaction.php new file mode 100644 index 000000000..e36bd2ef3 --- /dev/null +++ b/app/Util/DatabaseTransaction.php @@ -0,0 +1,42 @@ +callback); + } +} diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php index cb091b869..d4f7d2c8f 100644 --- a/app/Util/HtmlDescriptionFilter.php +++ b/app/Util/HtmlDescriptionFilter.php @@ -4,7 +4,6 @@ namespace BookStack\Util; use DOMAttr; use DOMElement; -use DOMNamedNodeMap; use DOMNode; /** @@ -25,6 +24,7 @@ class HtmlDescriptionFilter 'ul' => [], 'li' => [], 'strong' => [], + 'span' => [], 'em' => [], 'br' => [], ]; @@ -59,7 +59,6 @@ class HtmlDescriptionFilter return; } - /** @var DOMNamedNodeMap $attrs */ $attrs = $element->attributes; for ($i = $attrs->length - 1; $i >= 0; $i--) { /** @var DOMAttr $attr */ @@ -70,7 +69,8 @@ class HtmlDescriptionFilter } } - foreach ($element->childNodes as $child) { + $childNodes = [...$element->childNodes]; + foreach ($childNodes as $child) { if ($child instanceof DOMElement) { static::filterElement($child); } diff --git a/composer.json b/composer.json index 09f08daee..2ea1d802b 100644 --- a/composer.json +++ b/composer.json @@ -38,8 +38,7 @@ "socialiteproviders/microsoft-azure": "^5.1", "socialiteproviders/okta": "^4.2", "socialiteproviders/twitch": "^5.3", - "ssddanbrown/htmldiff": "^2.0.0", - "ssddanbrown/symfony-mailer": "7.2.x-dev" + "ssddanbrown/htmldiff": "^2.0.0" }, "require-dev": { "fakerphp/faker": "^1.21", diff --git a/composer.lock b/composer.lock index c4f1a1638..c42f19156 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d9b8455cf3ec02c21bea7ee94463331b", + "content-hash": "b7695cb9945ec550970c67da96934daf", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.349.2", + "version": "3.351.7", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "63cc727845f077d17cb94791deb327249e1626ce" + "reference": "9506d7fdb3cb84f8d7b175c594db9993264814be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/63cc727845f077d17cb94791deb327249e1626ce", - "reference": "63cc727845f077d17cb94791deb327249e1626ce", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/9506d7fdb3cb84f8d7b175c594db9993264814be", + "reference": "9506d7fdb3cb84f8d7b175c594db9993264814be", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.349.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.351.7" }, - "time": "2025-07-03T18:08:27+00:00" + "time": "2025-07-25T18:06:34+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1955,16 +1955,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.5", + "version": "v0.3.6", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1" + "reference": "86a8b692e8661d0fb308cec64f3d176821323077" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/57b8f7efe40333cdb925700891c7d7465325d3b1", - "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1", + "url": "https://api.github.com/repos/laravel/prompts/zipball/86a8b692e8661d0fb308cec64f3d176821323077", + "reference": "86a8b692e8661d0fb308cec64f3d176821323077", "shasum": "" }, "require": { @@ -2008,9 +2008,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.5" + "source": "https://github.com/laravel/prompts/tree/v0.3.6" }, - "time": "2025-02-11T13:34:40+00:00" + "time": "2025-07-07T14:17:42+00:00" }, { "name": "laravel/serializable-closure", @@ -2075,16 +2075,16 @@ }, { "name": "laravel/socialite", - "version": "v5.21.0", + "version": "v5.23.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "d83639499ad14985c9a6a9713b70073300ce998d" + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/d83639499ad14985c9a6a9713b70073300ce998d", - "reference": "d83639499ad14985c9a6a9713b70073300ce998d", + "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", "shasum": "" }, "require": { @@ -2143,7 +2143,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2025-05-19T12:56:37+00:00" + "time": "2025-07-23T14:16:08+00:00" }, { "name": "laravel/tinker", @@ -2213,16 +2213,16 @@ }, { "name": "league/commonmark", - "version": "2.7.0", + "version": "2.7.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", "shasum": "" }, "require": { @@ -2251,7 +2251,7 @@ "symfony/process": "^5.4 | ^6.0 | ^7.0", "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", "unleashedtech/php-coding-standard": "^3.1.1", - "vimeo/psalm": "^4.24.0 || ^5.0.0" + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, "suggest": { "symfony/yaml": "v2.3+ required if using the Front Matter extension" @@ -2316,7 +2316,7 @@ "type": "tidelift" } ], - "time": "2025-05-05T12:20:28+00:00" + "time": "2025-07-20T12:47:49+00:00" }, { "name": "league/config", @@ -3049,16 +3049,16 @@ }, { "name": "masterminds/html5", - "version": "2.9.0", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + "reference": "fcf91eb64359852f00d921887b219479b4f21251" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", "shasum": "" }, "require": { @@ -3110,9 +3110,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" }, - "time": "2024-03-31T07:05:07+00:00" + "time": "2025-07-25T09:04:22+00:00" }, { "name": "monolog/monolog", @@ -4894,16 +4894,16 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v8.8.0", + "version": "v8.9.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "3de493bdddfd1f051249af725c7e0d2c38fed740" + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/3de493bdddfd1f051249af725c7e0d2c38fed740", - "reference": "3de493bdddfd1f051249af725c7e0d2c38fed740", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9", "shasum": "" }, "require": { @@ -4911,7 +4911,8 @@ "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 || 6.5.14 || 7.5.20 || 8.5.41" + "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41", + "rawr/cross-data-providers": "^2.0.0" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -4953,9 +4954,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.8.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0" }, - "time": "2025-03-23T17:59:05+00:00" + "time": "2025-07-11T13:20:48+00:00" }, { "name": "socialiteproviders/discord", @@ -5325,80 +5326,6 @@ ], "time": "2025-07-07T11:55:59+00:00" }, - { - "name": "ssddanbrown/symfony-mailer", - "version": "7.2.x-dev", - "source": { - "type": "git", - "url": "https://github.com/ssddanbrown/symfony-mailer.git", - "reference": "e9de8dccd76a63fc23475016e6574da6f5f12a2d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ssddanbrown/symfony-mailer/zipball/e9de8dccd76a63fc23475016e6574da6f5f12a2d", - "reference": "e9de8dccd76a63fc23475016e6574da6f5f12a2d", - "shasum": "" - }, - "require": { - "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.2", - "psr/event-dispatcher": "^1", - "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/mime": "^7.2", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/messenger": "<6.4", - "symfony/mime": "<6.4", - "symfony/twig-bridge": "<6.4" - }, - "replace": { - "symfony/mailer": "^7.0" - }, - "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/twig-bridge": "^6.4|^7.0" - }, - "default-branch": true, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Mailer\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Dan Brown", - "homepage": "https://danb.me" - }, - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Helps sending emails", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/ssddanbrown/symfony-mailer/tree/7.2" - }, - "time": "2025-01-11T14:57:07+00:00" - }, { "name": "symfony/clock", "version": "v7.3.0", @@ -6189,6 +6116,86 @@ ], "time": "2025-06-28T08:24:55+00:00" }, + { + "name": "symfony/mailer", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/mime": "^7.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, { "name": "symfony/mime", "version": "v7.3.0", @@ -8131,16 +8138,16 @@ }, { "name": "larastan/larastan", - "version": "v3.5.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "e8ccd73008487ba91da9877b373f8c447743f1ce" + "reference": "6431d010dd383a9279eb8874a76ddb571738564a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/e8ccd73008487ba91da9877b373f8c447743f1ce", - "reference": "e8ccd73008487ba91da9877b373f8c447743f1ce", + "url": "https://api.github.com/repos/larastan/larastan/zipball/6431d010dd383a9279eb8874a76ddb571738564a", + "reference": "6431d010dd383a9279eb8874a76ddb571738564a", "shasum": "" }, "require": { @@ -8208,7 +8215,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.5.0" + "source": "https://github.com/larastan/larastan/tree/v3.6.0" }, "funding": [ { @@ -8216,7 +8223,7 @@ "type": "github" } ], - "time": "2025-06-19T22:41:50+00:00" + "time": "2025-07-11T06:52:52+00:00" }, { "name": "mockery/mockery", @@ -8580,16 +8587,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.17", + "version": "2.1.20", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053" + "reference": "a9ccfef95210f92ba6feea6e8d1eef42b5605499" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/89b5ef665716fa2a52ecd2633f21007a6a349053", - "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a9ccfef95210f92ba6feea6e8d1eef42b5605499", + "reference": "a9ccfef95210f92ba6feea6e8d1eef42b5605499", "shasum": "" }, "require": { @@ -8634,7 +8641,7 @@ "type": "github" } ], - "time": "2025-05-21T20:55:28+00:00" + "time": "2025-07-26T20:45:26+00:00" }, { "name": "phpunit/php-code-coverage", @@ -8973,16 +8980,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.26", + "version": "11.5.27", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "4ad8fe263a0b55b54a8028c38a18e3c5bef312e0" + "reference": "446d43867314781df7e9adf79c3ec7464956fd8f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ad8fe263a0b55b54a8028c38a18e3c5bef312e0", - "reference": "4ad8fe263a0b55b54a8028c38a18e3c5bef312e0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/446d43867314781df7e9adf79c3ec7464956fd8f", + "reference": "446d43867314781df7e9adf79c3ec7464956fd8f", "shasum": "" }, "require": { @@ -8992,7 +8999,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.1", + "myclabs/deep-copy": "^1.13.3", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", @@ -9054,7 +9061,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.26" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.27" }, "funding": [ { @@ -9078,7 +9085,7 @@ "type": "tidelift" } ], - "time": "2025-07-04T05:58:21+00:00" + "time": "2025-07-11T04:10:06+00:00" }, { "name": "sebastian/cli-parser", @@ -10318,9 +10325,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "ssddanbrown/symfony-mailer": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php index 5d0b4f892..cdb019dd3 100644 --- a/database/factories/Exports/ImportFactory.php +++ b/database/factories/Exports/ImportFactory.php @@ -24,6 +24,7 @@ class ImportFactory extends Factory 'path' => 'uploads/files/imports/' . Str::random(10) . '.zip', 'name' => $this->faker->words(3, true), 'type' => 'book', + 'size' => rand(1, 1001), 'metadata' => '{"name": "My book"}', 'created_at' => User::factory(), ]; diff --git a/dev/api/requests/imports-run.json b/dev/api/requests/imports-run.json new file mode 100644 index 000000000..836a66f3a --- /dev/null +++ b/dev/api/requests/imports-run.json @@ -0,0 +1,4 @@ +{ + "parent_type": "book", + "parent_id": 28 +} \ No newline at end of file diff --git a/dev/api/responses/imports-create.json b/dev/api/responses/imports-create.json new file mode 100644 index 000000000..997758799 --- /dev/null +++ b/dev/api/responses/imports-create.json @@ -0,0 +1,10 @@ +{ + "type": "chapter", + "name": "Pension Providers", + "created_by": 1, + "size": 2757, + "path": "uploads\/files\/imports\/ghnxmS3u9QxLWu82.zip", + "updated_at": "2025-07-18T14:50:27.000000Z", + "created_at": "2025-07-18T14:50:27.000000Z", + "id": 31 +} \ No newline at end of file diff --git a/dev/api/responses/imports-list.json b/dev/api/responses/imports-list.json new file mode 100644 index 000000000..7451e4437 --- /dev/null +++ b/dev/api/responses/imports-list.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "id": 25, + "name": "IT Department", + "size": 618462, + "type": "book", + "created_by": 1, + "created_at": "2024-12-20T18:40:38.000000Z", + "updated_at": "2024-12-20T18:40:38.000000Z" + }, + { + "id": 27, + "name": "Clients", + "size": 15364, + "type": "chapter", + "created_by": 1, + "created_at": "2025-03-20T12:41:44.000000Z", + "updated_at": "2025-03-20T12:41:44.000000Z" + } + ], + "total": 2 +} \ No newline at end of file diff --git a/dev/api/responses/imports-read.json b/dev/api/responses/imports-read.json new file mode 100644 index 000000000..e256854d1 --- /dev/null +++ b/dev/api/responses/imports-read.json @@ -0,0 +1,51 @@ +{ + "id": 25, + "name": "IT Department", + "path": "uploads\/files\/imports\/7YOpZ6sGIEbYdRFL.zip", + "size": 618462, + "type": "book", + "created_by": 1, + "created_at": "2024-12-20T18:40:38.000000Z", + "updated_at": "2024-12-20T18:40:38.000000Z", + "details": { + "id": 4, + "name": "IT Department", + "chapters": [ + { + "id": 3, + "name": "Server Systems", + "priority": 1, + "pages": [ + { + "id": 22, + "name": "prod-aws-stonehawk", + "priority": 0, + "attachments": [], + "images": [], + "tags": [] + } + ], + "tags": [] + } + ], + "pages": [ + { + "id": 23, + "name": "Member Onboarding Guide", + "priority": 0, + "attachments": [], + "images": [], + "tags": [] + }, + { + "id": 25, + "name": "IT Holiday Party Event", + "priority": 2, + "attachments": [], + "images": [], + "tags": [] + } + ], + "tags": [] + } +} \ No newline at end of file diff --git a/dev/api/responses/imports-run.json b/dev/api/responses/imports-run.json new file mode 100644 index 000000000..90b34d6aa --- /dev/null +++ b/dev/api/responses/imports-run.json @@ -0,0 +1,14 @@ +{ + "id": 1067, + "book_id": 28, + "slug": "pension-providers", + "name": "Pension Providers", + "description": "Details on the various pension providers that are available", + "priority": 7, + "created_at": "2025-07-18T14:53:35.000000Z", + "updated_at": "2025-07-18T14:53:36.000000Z", + "created_by": 1, + "updated_by": 1, + "owned_by": 1, + "default_template_id": null +} \ No newline at end of file diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js index cd8bf279f..63387d612 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.js @@ -13,7 +13,7 @@ const entryPoints = { 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'), + markdown: path.join(__dirname, '../../resources/js/markdown/index.mts'), wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'), }; diff --git a/dev/licensing/js-library-licenses.txt b/dev/licensing/js-library-licenses.txt index d09010142..b5fa446a4 100644 --- a/dev/licensing/js-library-licenses.txt +++ b/dev/licensing/js-library-licenses.txt @@ -345,7 +345,7 @@ Link: tj/co codemirror License: MIT License File: node_modules/codemirror/LICENSE -Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <*******@*****.***> and others +Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others Source: https://github.com/codemirror/basic-setup.git Link: https://github.com/codemirror/basic-setup.git ----------- @@ -711,13 +711,13 @@ eslint-scope License: BSD-2-Clause License File: node_modules/eslint-scope/LICENSE Copyright: Copyright (C) 2012-2013 Yusuke Suzuki (twitter: @Constellation) and other contributors. -Source: eslint/js +Source: https://github.com/eslint/js.git Link: https://github.com/eslint/js/blob/main/packages/eslint-scope/README.md ----------- eslint-visitor-keys License: Apache-2.0 License File: node_modules/eslint-visitor-keys/LICENSE -Source: eslint/js +Source: https://github.com/eslint/js.git Link: https://github.com/eslint/js/blob/main/packages/eslint-visitor-keys/README.md ----------- eslint @@ -731,7 +731,7 @@ License: BSD-2-Clause License File: node_modules/espree/LICENSE Copyright: Copyright (c) Open JS Foundation All rights reserved. -Source: eslint/js +Source: https://github.com/eslint/js.git Link: https://github.com/eslint/js/blob/main/packages/espree/README.md ----------- esprima @@ -1252,6 +1252,13 @@ Copyright: Copyright (c) 2019 Inspect JS Source: git+https://github.com/inspect-js/is-map.git Link: https://github.com/inspect-js/is-map#readme ----------- +is-negative-zero +License: MIT +License File: node_modules/is-negative-zero/LICENSE +Copyright: Copyright (c) 2014 Jordan Harband +Source: git://github.com/inspect-js/is-negative-zero.git +Link: https://github.com/inspect-js/is-negative-zero +----------- is-number-object License: MIT License File: node_modules/is-number-object/LICENSE @@ -2493,6 +2500,13 @@ Copyright: Copyright (c) 2016-2022 Isaac Z. Schlueter <*@***.**>, James Talmage Source: tapjs/stack-utils Link: tapjs/stack-utils ----------- +stop-iteration-iterator +License: MIT +License File: node_modules/stop-iteration-iterator/LICENSE +Copyright: Copyright (c) 2023 Jordan Harband +Source: git+https://github.com/ljharb/stop-iteration-iterator.git +Link: https://github.com/ljharb/stop-iteration-iterator#readme +----------- string-length License: MIT License File: node_modules/string-length/license @@ -2992,6 +3006,13 @@ Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors Source: https://github.com/babel/babel.git Link: https://github.com/babel/babel.git ----------- +@babel/helper-globals +License: MIT +License File: node_modules/@babel/helper-globals/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://github.com/babel/babel.git +----------- @babel/helper-module-imports License: MIT License File: node_modules/@babel/helper-module-imports/LICENSE @@ -3038,7 +3059,7 @@ Link: https://github.com/babel/babel.git License: MIT License File: node_modules/@babel/helpers/LICENSE Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors -Copyright (c) 2014-present, Facebook, Inc. (ONLY ./src/helpers/regeneratorRuntime.js) +Copyright (c) 2014-present, Facebook, Inc. (ONLY ./src/helpers/regenerator* files) Source: https://github.com/babel/babel.git Link: https://babel.dev/docs/en/next/babel-helpers ----------- @@ -3233,7 +3254,7 @@ Link: https://github.com/codemirror/lang-javascript.git @codemirror/lang-json License: MIT License File: node_modules/@codemirror/lang-json/LICENSE -Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <*******@*****.***> and others +Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others Source: https://github.com/codemirror/lang-json.git Link: https://github.com/codemirror/lang-json.git ----------- @@ -3247,7 +3268,7 @@ Link: https://github.com/codemirror/lang-markdown.git @codemirror/lang-php License: MIT License File: node_modules/@codemirror/lang-php/LICENSE -Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <*******@*****.***> and others +Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others Source: https://github.com/codemirror/lang-php.git Link: https://github.com/codemirror/lang-php.git ----------- @@ -3337,7 +3358,7 @@ Link: https://github.com/eslint-community/regexpp#readme License: Apache-2.0 License File: node_modules/@eslint/config-array/LICENSE Source: git+https://github.com/eslint/rewrite.git -Link: https://github.com/eslint/rewrite#readme +Link: https://github.com/eslint/rewrite/tree/main/packages/config-array#readme ----------- @eslint/config-helpers License: Apache-2.0 @@ -3349,7 +3370,7 @@ Link: https://github.com/eslint/rewrite/tree/main/packages/config-helpers#readme License: Apache-2.0 License File: node_modules/@eslint/core/LICENSE Source: git+https://github.com/eslint/rewrite.git -Link: https://github.com/eslint/rewrite#readme +Link: https://github.com/eslint/rewrite/tree/main/packages/core#readme ----------- @eslint/eslintrc License: MIT @@ -3373,7 +3394,7 @@ Link: https://github.com/eslint/rewrite#readme License: Apache-2.0 License File: node_modules/@eslint/plugin-kit/LICENSE Source: git+https://github.com/eslint/rewrite.git -Link: https://github.com/eslint/rewrite#readme +Link: https://github.com/eslint/rewrite/tree/main/packages/plugin-kit#readme ----------- @humanfs/core License: Apache-2.0 @@ -3514,9 +3535,9 @@ Link: https://github.com/jestjs/jest.git @jridgewell/gen-mapping License: MIT License File: node_modules/@jridgewell/gen-mapping/LICENSE -Copyright: Copyright 2022 Justin Ridgewell <**********@******.***> -Source: https://github.com/jridgewell/gen-mapping -Link: https://github.com/jridgewell/gen-mapping +Copyright: Copyright 2024 Justin Ridgewell <******@*********.****> +Source: git+https://github.com/jridgewell/sourcemaps.git +Link: https://github.com/jridgewell/sourcemaps/tree/main/packages/gen-mapping ----------- @jridgewell/resolve-uri License: MIT @@ -3525,26 +3546,19 @@ Copyright: Copyright 2019 Justin Ridgewell <**********@******.***> Source: https://github.com/jridgewell/resolve-uri Link: https://github.com/jridgewell/resolve-uri ----------- -@jridgewell/set-array -License: MIT -License File: node_modules/@jridgewell/set-array/LICENSE -Copyright: Copyright 2022 Justin Ridgewell <**********@******.***> -Source: https://github.com/jridgewell/set-array -Link: https://github.com/jridgewell/set-array ------------ @jridgewell/sourcemap-codec License: MIT License File: node_modules/@jridgewell/sourcemap-codec/LICENSE -Copyright: Copyright (c) 2015 Rich Harris -Source: git+https://github.com/jridgewell/sourcemap-codec.git -Link: git+https://github.com/jridgewell/sourcemap-codec.git +Copyright: Copyright 2024 Justin Ridgewell <******@*********.****> +Source: git+https://github.com/jridgewell/sourcemaps.git +Link: https://github.com/jridgewell/sourcemaps/tree/main/packages/sourcemap-codec ----------- @jridgewell/trace-mapping License: MIT License File: node_modules/@jridgewell/trace-mapping/LICENSE -Copyright: Copyright 2022 Justin Ridgewell <******@*********.****> -Source: git+https://github.com/jridgewell/trace-mapping.git -Link: git+https://github.com/jridgewell/trace-mapping.git +Copyright: Copyright 2024 Justin Ridgewell <******@*********.****> +Source: git+https://github.com/jridgewell/sourcemaps.git +Link: https://github.com/jridgewell/sourcemaps/tree/main/packages/trace-mapping ----------- @lezer/common License: MIT @@ -3813,6 +3827,27 @@ License: MIT Source: https://www.github.com/DefinitelyTyped/DefinitelyTyped.git Link: https://www.github.com/DefinitelyTyped/DefinitelyTyped.git ----------- +@types/linkify-it +License: MIT +License File: node_modules/@types/linkify-it/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/linkify-it +----------- +@types/markdown-it +License: MIT +License File: node_modules/@types/markdown-it/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/markdown-it +----------- +@types/mdurl +License: MIT +License File: node_modules/@types/mdurl/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/mdurl +----------- @types/node License: MIT License File: node_modules/@types/node/LICENSE diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt index 30b74ece0..cd4c1d490 100644 --- a/dev/licensing/php-library-licenses.txt +++ b/dev/licensing/php-library-licenses.txt @@ -543,13 +543,6 @@ Copyright: Copyright (c) 2024 Nathan Herald, Rohland de Charmoy, Dan Brown Source: https://codeberg.org/danb/HtmlDiff Link: https://codeberg.org/danb/HtmlDiff ----------- -ssddanbrown/symfony-mailer -License: MIT -License File: vendor/ssddanbrown/symfony-mailer/LICENSE -Copyright: Copyright (c) 2019-present Fabien Potencier -Source: https://github.com/ssddanbrown/symfony-mailer.git -Link: https://symfony.com ------------ symfony/clock License: MIT License File: vendor/symfony/clock/LICENSE @@ -620,6 +613,13 @@ Copyright: Copyright (c) 2004-present Fabien Potencier Source: https://github.com/symfony/http-kernel.git Link: https://symfony.com ----------- +symfony/mailer +License: MIT +License File: vendor/symfony/mailer/LICENSE +Copyright: Copyright (c) 2019-present Fabien Potencier +Source: https://github.com/symfony/mailer.git +Link: https://symfony.com +----------- symfony/mime License: MIT License File: vendor/symfony/mime/LICENSE diff --git a/lang/ar/common.php b/lang/ar/common.php index a8fb18e86..44d057d7e 100644 --- a/lang/ar/common.php +++ b/lang/ar/common.php @@ -30,8 +30,8 @@ return [ 'create' => 'إنشاء', 'update' => 'تحديث', 'edit' => 'تعديل', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => 'أرشف', + 'unarchive' => 'إلغاء الأرشفة', 'sort' => 'سرد', 'move' => 'نقل', 'copy' => 'نسخ', diff --git a/lang/ar/editor.php b/lang/ar/editor.php index c656ba5a0..b3a8fd3c7 100644 --- a/lang/ar/editor.php +++ b/lang/ar/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'نص مرتفع', 'subscript' => 'نص منخفض', 'text_color' => 'لون النص', + 'highlight_color' => 'لون التمييز', 'custom_color' => 'لون مخصص', 'remove_color' => 'إزالة اللون', 'background_color' => 'لون الخلفية', diff --git a/lang/ar/entities.php b/lang/ar/entities.php index c07fba542..54c3f7447 100644 --- a/lang/ar/entities.php +++ b/lang/ar/entities.php @@ -248,7 +248,7 @@ return [ 'pages_edit_switch_to_markdown_stable' => '(محتوى مستقر)', 'pages_edit_switch_to_wysiwyg' => 'التبديل إلى محرر ما تراه هو ما تحصل عليه -WYSIWYG-', 'pages_edit_switch_to_new_wysiwyg' => 'التبديل إلى محرر ما تراه هو ما تحصل عليه الجديد -new WYSIWYG-', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(في الاختبار التجريبي)', 'pages_edit_set_changelog' => 'تثبيت سجل التعديل', 'pages_edit_enter_changelog_desc' => 'ضع وصف مختصر للتعديلات التي تمت', 'pages_edit_enter_changelog' => 'أدخل سجل التعديل', @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'إدخال رسمة', 'pages_md_show_preview' => 'عرض المعاينة', 'pages_md_sync_scroll' => 'مزامنة معاينة التمرير', + 'pages_md_plain_editor' => 'محرر النصوص العادي', 'pages_drawing_unsaved' => 'تم العثور على رسم غير محفوظ', 'pages_drawing_unsaved_confirm' => 'تم العثور على بيانات رسم غير محفوظة من محاولة حفظ رسم سابقة فاشلة. هل ترغب في استعادة هذا الرسم غير المحفوظ ومواصلة تحريره؟', 'pages_not_in_chapter' => 'صفحة ليست في فصل', diff --git a/lang/bg/editor.php b/lang/bg/editor.php index e01206e4a..a75128953 100644 --- a/lang/bg/editor.php +++ b/lang/bg/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Горен индекс', 'subscript' => 'Долен индекс', 'text_color' => 'Цвят на текста', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Цвят по избор', 'remove_color' => 'Премахване на цвят', 'background_color' => 'Фонов цвят', diff --git a/lang/bg/entities.php b/lang/bg/entities.php index 050b7e319..973a305ea 100644 --- a/lang/bg/entities.php +++ b/lang/bg/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Вмъкни рисунка', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Страницата не принадлежи в никоя глава', diff --git a/lang/bn/editor.php b/lang/bn/editor.php index 50b4ce4d8..d8476eeae 100644 --- a/lang/bn/editor.php +++ b/lang/bn/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Text color', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Custom color', 'remove_color' => 'Remove color', 'background_color' => 'Background color', diff --git a/lang/bn/entities.php b/lang/bn/entities.php index 561022ad6..ef625a3d2 100644 --- a/lang/bn/entities.php +++ b/lang/bn/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insert Drawing', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Page is not in a chapter', diff --git a/lang/bs/editor.php b/lang/bs/editor.php index 752c6f3f7..0d250e9a7 100644 --- a/lang/bs/editor.php +++ b/lang/bs/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Text color', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Custom color', 'remove_color' => 'Remove color', 'background_color' => 'Background color', diff --git a/lang/bs/entities.php b/lang/bs/entities.php index 1f77db568..28b5c5483 100644 --- a/lang/bs/entities.php +++ b/lang/bs/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insert Drawing', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Page is not in a chapter', diff --git a/lang/ca/editor.php b/lang/ca/editor.php index 331e59631..5115a1c15 100644 --- a/lang/ca/editor.php +++ b/lang/ca/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superíndex', 'subscript' => 'Subíndex', 'text_color' => 'Color del text', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Color personalitzat', 'remove_color' => 'Elimina el color', 'background_color' => 'Color de fons', diff --git a/lang/ca/entities.php b/lang/ca/entities.php index af62a4c0b..1b01cea39 100644 --- a/lang/ca/entities.php +++ b/lang/ca/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insereix un dibuix', 'pages_md_show_preview' => 'Mostra la visualització prèvia', 'pages_md_sync_scroll' => 'Sincronitza el desplaçament de la visualització prèvia', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'S’ha trobat un dibuix sense desar', 'pages_drawing_unsaved_confirm' => 'S’han trobat dades d’un dibuix d’un intent anterior de desar un dibuix. Voleu restaurar aquest dibuix no desat per a reprendre’n l’edició?', 'pages_not_in_chapter' => 'La pàgina no és un capítol', diff --git a/lang/cs/editor.php b/lang/cs/editor.php index 15daa778c..d67283b72 100644 --- a/lang/cs/editor.php +++ b/lang/cs/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'horní index', 'subscript' => 'Dolní index', 'text_color' => 'Barva textu:', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Vlastní barva', 'remove_color' => 'Odstranit barvu', 'background_color' => 'Barva pozadí', diff --git a/lang/cs/entities.php b/lang/cs/entities.php index 2f7f09ebc..687f1503e 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Vložit kresbu', 'pages_md_show_preview' => 'Zobrazit náhled', 'pages_md_sync_scroll' => 'Synchronizovat náhled', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Nalezen neuložený výkres', 'pages_drawing_unsaved_confirm' => 'Byly nalezeny neuložené kresby z předchozí neúspěšné pokusu o uložení kresby. Chcete je obnovit a pokračovat v úpravě této neuložené kresby?', 'pages_not_in_chapter' => 'Stránka není v kapitole', @@ -395,7 +396,7 @@ return [ 'comment_none' => 'Žádné komentáře k zobrazení', 'comment_placeholder' => 'Zde zadejte komentář', 'comment_thread_count' => '{0}:count vláken komentářů|{1}:count vlákno komentářů|[2,4]:count vlákna komentářů|[5,*]:count vláken komentářů', - 'comment_archived_count' => ':count archivováno', + 'comment_archived_count' => '[0,1]:count archivováno|[2,4]:count archivována|[5,*]:count archivováno', 'comment_archived_threads' => 'Archivovaná vlákna', 'comment_save' => 'Uložit komentář', 'comment_new' => 'Nový komentář', diff --git a/lang/cy/editor.php b/lang/cy/editor.php index 93e1aed20..980099ffd 100644 --- a/lang/cy/editor.php +++ b/lang/cy/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Uwchysgrif', 'subscript' => 'Isysgrif', 'text_color' => 'Lliw testun', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Lliw addasu', 'remove_color' => 'Dileu lliw', 'background_color' => 'Lliw cefnder', diff --git a/lang/cy/entities.php b/lang/cy/entities.php index 7ddc29e7b..3a5473ecb 100644 --- a/lang/cy/entities.php +++ b/lang/cy/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Mewnosod Llun', 'pages_md_show_preview' => 'Dangos rhagolwg', 'pages_md_sync_scroll' => 'Cydamseru sgrôl ragolwg', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Canfuwyd Llun heb ei Gadw', 'pages_drawing_unsaved_confirm' => 'Canfuwyd data llun heb ei gadw o ymgais aflwyddiannus blaenorol i gadw llun. Hoffech chi adfer a pharhau i olygu\'r llun heb ei gadw?', 'pages_not_in_chapter' => 'Nid yw\'r dudalen mewn pennod', diff --git a/lang/da/editor.php b/lang/da/editor.php index 2f9b9f936..4c8aa0f72 100644 --- a/lang/da/editor.php +++ b/lang/da/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Hævet', 'subscript' => 'Sænket', 'text_color' => 'Tekstfarve', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Tilpasset farve', 'remove_color' => 'Fjern farve', 'background_color' => 'Baggrundsfarve', diff --git a/lang/da/entities.php b/lang/da/entities.php index 1ef9ee4d5..a90f6e605 100644 --- a/lang/da/entities.php +++ b/lang/da/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Indsæt tegning', 'pages_md_show_preview' => 'Vis forhåndsvisning', 'pages_md_sync_scroll' => 'Rulning af forhåndsvisning af synkronisering', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Ikke gemt tegning fundet', 'pages_drawing_unsaved_confirm' => 'Der blev fundet ikke-gemte tegningsdata fra et tidligere mislykket forsøg på at gemme en tegning. Vil du gendanne og fortsætte med at redigere denne ikke-gemte tegning?', 'pages_not_in_chapter' => 'Side er ikke i et kapitel', diff --git a/lang/de/editor.php b/lang/de/editor.php index 41ec74e52..c824466ff 100644 --- a/lang/de/editor.php +++ b/lang/de/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Hochgestellt', 'subscript' => 'Tiefgestellt', 'text_color' => 'Schriftfarbe', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Benutzerdefinierte Farbe', 'remove_color' => 'Farbe entfernen', 'background_color' => 'Hintergrundfarbe', diff --git a/lang/de/entities.php b/lang/de/entities.php index 144a93636..e4e1bdafd 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Zeichnung einfügen', 'pages_md_show_preview' => 'Vorschau anzeigen', 'pages_md_sync_scroll' => 'Vorschau synchronisieren', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Ungespeicherte Zeichnung gefunden', 'pages_drawing_unsaved_confirm' => 'Es wurden ungespeicherte Zeichnungsdaten von einem früheren, fehlgeschlagenen Versuch, die Zeichnung zu speichern, gefunden. Möchten Sie diese ungespeicherte Zeichnung wiederherstellen und weiter bearbeiten?', 'pages_not_in_chapter' => 'Seite ist in keinem Kapitel', diff --git a/lang/de_informal/editor.php b/lang/de_informal/editor.php index 3d6e24a23..7f232659b 100644 --- a/lang/de_informal/editor.php +++ b/lang/de_informal/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Hochgestellt', 'subscript' => 'Tiefgestellt', 'text_color' => 'Textfarbe', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Benutzerdefinierte Farbe', 'remove_color' => 'Farbe entfernen', 'background_color' => 'Hintergrundfarbe', diff --git a/lang/de_informal/entities.php b/lang/de_informal/entities.php index d78addf2a..38f4eeb56 100644 --- a/lang/de_informal/entities.php +++ b/lang/de_informal/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Zeichnung einfügen', 'pages_md_show_preview' => 'Vorschau anzeigen', 'pages_md_sync_scroll' => 'Vorschau synchronisieren', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Ungespeicherte Zeichnung gefunden', 'pages_drawing_unsaved_confirm' => 'Es wurden ungespeicherte Zeichnungsdaten von einem früheren, fehlgeschlagenen Versuch, die Zeichnung zu speichern, gefunden. Möchtest du diese ungespeicherte Zeichnung wiederherstellen und weiter bearbeiten?', 'pages_not_in_chapter' => 'Seite ist in keinem Kapitel', diff --git a/lang/el/editor.php b/lang/el/editor.php index 701b384bd..9952e7fdf 100644 --- a/lang/el/editor.php +++ b/lang/el/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Εκθέτης', 'subscript' => 'Δείκτης', 'text_color' => 'Χρώμα κειμένου', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Προσαρμογή χρώματος', 'remove_color' => 'Αφαίρεση χρώματος', 'background_color' => 'Χρώμα φόντου', diff --git a/lang/el/entities.php b/lang/el/entities.php index 3220933cd..b9ccd6464 100644 --- a/lang/el/entities.php +++ b/lang/el/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Εισαγωγή Σχεδίου', 'pages_md_show_preview' => 'Εμφάνιση προεπισκόπησης', 'pages_md_sync_scroll' => 'Συγχρονισμός προεπισκόπησης', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Η σελίδα δεν είναι σε κεφάλαιο', diff --git a/lang/en/editor.php b/lang/en/editor.php index 752c6f3f7..0d250e9a7 100644 --- a/lang/en/editor.php +++ b/lang/en/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Text color', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Custom color', 'remove_color' => 'Remove color', 'background_color' => 'Background color', diff --git a/lang/en/entities.php b/lang/en/entities.php index 561022ad6..ef625a3d2 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insert Drawing', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Page is not in a chapter', diff --git a/lang/es/editor.php b/lang/es/editor.php index 15f1dd9b7..78920720d 100644 --- a/lang/es/editor.php +++ b/lang/es/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superíndice', 'subscript' => 'Subíndice', 'text_color' => 'Color de texto', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Color personalizado', 'remove_color' => 'Eliminar color', 'background_color' => 'Color de fondo', diff --git a/lang/es/entities.php b/lang/es/entities.php index 303730f03..9263f06e1 100644 --- a/lang/es/entities.php +++ b/lang/es/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insertar Dibujo', 'pages_md_show_preview' => 'Mostrar vista previa', 'pages_md_sync_scroll' => 'Sincronizar desplazamiento de vista previa', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Encontrado dibujo sin guardar', 'pages_drawing_unsaved_confirm' => 'Se encontraron datos no guardados del dibujo de un intento de guardado fallido. ¿Desea restaurar y continuar editando el dibujo no guardado?', 'pages_not_in_chapter' => 'La página no está en un capítulo', diff --git a/lang/es_AR/editor.php b/lang/es_AR/editor.php index 7f4f7a0b8..73d8e6f08 100644 --- a/lang/es_AR/editor.php +++ b/lang/es_AR/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superíndice', 'subscript' => 'Subíndice', 'text_color' => 'Color del texto', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Color personalizado', 'remove_color' => 'Eliminar color', 'background_color' => 'Color de fondo', diff --git a/lang/es_AR/entities.php b/lang/es_AR/entities.php index 729d19027..f0b13ed1a 100644 --- a/lang/es_AR/entities.php +++ b/lang/es_AR/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insertar Dibujo', 'pages_md_show_preview' => 'Mostrar vista previa', 'pages_md_sync_scroll' => 'Sincronizar desplazamiento de vista previa', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Encontrado dibujo sin guardar', 'pages_drawing_unsaved_confirm' => 'Se encontraron datos del dibujo no guardados durante un intento de guardado fallido anterior. ¿Desea restaurar y continuar editando el dibujo no guardado?', 'pages_not_in_chapter' => 'La página no esá en el capítulo', diff --git a/lang/et/editor.php b/lang/et/editor.php index e67e9b0f5..372865866 100644 --- a/lang/et/editor.php +++ b/lang/et/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Ülaindeks', 'subscript' => 'Alaindeks', 'text_color' => 'Teksti värv', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Kohandatud värv', 'remove_color' => 'Eemalda värv', 'background_color' => 'Taustavärv', diff --git a/lang/et/entities.php b/lang/et/entities.php index cc34d0a77..3193001da 100644 --- a/lang/et/entities.php +++ b/lang/et/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Lisa joonis', 'pages_md_show_preview' => 'Näita eelvaadet', 'pages_md_sync_scroll' => 'Sünkrooni eelvaate kerimine', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Leiti salvestamata joonis', 'pages_drawing_unsaved_confirm' => 'Varasemast ebaõnnestunud salvestuskatsest leiti salvestamata joonis. Kas soovid salvestamata joonise taastada ja selle muutmist jätkata?', 'pages_not_in_chapter' => 'Leht ei kuulu peatüki alla', diff --git a/lang/eu/editor.php b/lang/eu/editor.php index f6974e056..3ec398cb7 100644 --- a/lang/eu/editor.php +++ b/lang/eu/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Gain-eskripta', 'subscript' => 'Azpi-script', 'text_color' => 'Testuaren kolorea', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Kolore pertsonalizatua', 'remove_color' => 'Kolorea ezabatu', 'background_color' => 'Atzeko planoaren kolorea', diff --git a/lang/eu/entities.php b/lang/eu/entities.php index f42ce1d02..6d7bcbf81 100644 --- a/lang/eu/entities.php +++ b/lang/eu/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Txertatu marrazki berria', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Page is not in a chapter', diff --git a/lang/fa/activities.php b/lang/fa/activities.php index cd7e52ed2..07eb78f37 100644 --- a/lang/fa/activities.php +++ b/lang/fa/activities.php @@ -6,7 +6,7 @@ return [ // Pages - 'page_create' => 'تاریخ ایجاد', + 'page_create' => 'صفحه ایجاد شد', 'page_create_notification' => 'صفحه با موفقیت ایجاد شد', 'page_update' => 'به روزرسانی صفحه', 'page_update_notification' => 'صفحه با موفقیت به روزرسانی شد', @@ -85,12 +85,12 @@ return [ 'webhook_delete_notification' => 'وب هوک با موفقیت حذف شد', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => 'ورودی ایجاد شد', + 'import_create_notification' => 'فایل با موفقیت آپلود شد', + 'import_run' => 'آیتم واردشده بروزرسانی شد', + 'import_run_notification' => 'محتوا با موفقیت انتقال یافت', + 'import_delete' => 'آیتم ورودی حدف شده', + 'import_delete_notification' => 'آیتم واردشده با موفقیت حذف شد', // Users 'user_create' => 'کاربر ایجاد شده', @@ -128,12 +128,12 @@ return [ 'comment_delete' => 'نظر حذف شده', // Sort Rules - 'sort_rule_create' => 'created sort rule', - 'sort_rule_create_notification' => 'Sort rule successfully created', - 'sort_rule_update' => 'updated sort rule', - 'sort_rule_update_notification' => 'Sort rule successfully updated', - 'sort_rule_delete' => 'deleted sort rule', - 'sort_rule_delete_notification' => 'Sort rule successfully deleted', + 'sort_rule_create' => 'قانون مرتب‌سازی ایجاد شد', + 'sort_rule_create_notification' => 'قانون مرتب‌سازی با موفقیت ایجاد شد', + 'sort_rule_update' => 'قانون مرتب‌سازی به‌روزرسانی شد', + 'sort_rule_update_notification' => 'قانون مرتب‌سازی با موفقیت به‌روزرسانی شد', + 'sort_rule_delete' => 'قانون مرتب‌سازی حذف شد', + 'sort_rule_delete_notification' => 'قانون مرتب‌سازی با موفقیت حذف شد', // Other 'permissions_update' => 'به روزرسانی مجوزها', diff --git a/lang/fa/auth.php b/lang/fa/auth.php index c0902d4f9..c92f5f22b 100644 --- a/lang/fa/auth.php +++ b/lang/fa/auth.php @@ -91,7 +91,7 @@ return [ '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_option_backup_codes_desc' => 'این فرایند مجموعه‌ای از کدهای پشتیبان یک‌بار مصرف تولید می‌کند که هنگام ورود به سامانه جهت تأیید هویت باید از آن‌ها استفاده کنید. توصیه می‌شود این کدها را در محلّی امن و محفوظ نگهداری نمایید.', 'mfa_gen_confirm_and_enable' => 'تایید و فعال کنید', 'mfa_gen_backup_codes_title' => 'راه اندازی کدهای پشتیبان', 'mfa_gen_backup_codes_desc' => 'لیست کدهای زیر را در مکانی امن ذخیره کنید. هنگام دسترسی به سیستم، می توانید از یکی از کدها به عنوان مکانیزم احراز هویت دوم استفاده کنید.', diff --git a/lang/fa/common.php b/lang/fa/common.php index 9672ce2c1..49667a47c 100644 --- a/lang/fa/common.php +++ b/lang/fa/common.php @@ -30,8 +30,8 @@ return [ 'create' => 'ایجاد', 'update' => 'به‌روز رسانی', 'edit' => 'ويرايش', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => 'انتقال به بایگانی', + 'unarchive' => 'فعّال‌سازی دوباره (خروج از بایگانی)', 'sort' => 'مرتب سازی', 'move' => 'جابجایی', 'copy' => 'کپی', @@ -111,5 +111,5 @@ return [ 'terms_of_service' => 'شرایط خدمات', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'جست‌وجو در :appName', ]; diff --git a/lang/fa/components.php b/lang/fa/components.php index 576a50cca..18a01dc5d 100644 --- a/lang/fa/components.php +++ b/lang/fa/components.php @@ -34,8 +34,8 @@ return [ 'image_delete_success' => 'تصویر با موفقیت حذف شد', 'image_replace' => 'جایگزینی تصویر', 'image_replace_success' => 'تصویر با موفقیت به روز شد', - 'image_rebuild_thumbs' => 'Regenerate Size Variations', - 'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!', + 'image_rebuild_thumbs' => 'بازتولید اندازه‌های گوناگونی از تصویر', + 'image_rebuild_thumbs_success' => 'اندازه‌های گوناگونی از تصویر با موفقیت بازتولید شدند.', // Code Editor 'code_editor' => 'ویرایش کد', diff --git a/lang/fa/editor.php b/lang/fa/editor.php index fa95bb10b..a8b3d211a 100644 --- a/lang/fa/editor.php +++ b/lang/fa/editor.php @@ -13,7 +13,7 @@ return [ 'cancel' => 'لغو', 'save' => 'ذخیره', 'close' => 'بستن', - 'apply' => 'Apply', + 'apply' => 'اعمال', 'undo' => 'برگشت', 'redo' => 'از نو', 'left' => 'چپ', @@ -48,6 +48,7 @@ return [ 'superscript' => 'بالانویسی', 'subscript' => 'پایین نویسی', 'text_color' => 'رنگ متن', + 'highlight_color' => 'Highlight color', 'custom_color' => 'رنگ دلخواه', 'remove_color' => 'حذف رنگ', 'background_color' => 'رنگ زمینه', @@ -82,9 +83,9 @@ return [ 'table_properties' => 'تنظیمات جدول', 'table_properties_title' => 'تنظیمات جدول', 'delete_table' => 'حذف جدول', - 'table_clear_formatting' => 'Clear table formatting', - 'resize_to_contents' => 'Resize to contents', - 'row_header' => 'Row header', + 'table_clear_formatting' => 'حذف قالب‌بندی جدول', + 'resize_to_contents' => 'تغییر اندازه بر اساس محتوا', + 'row_header' => 'عنوان سطر', 'insert_row_before' => 'افزودن سطر به قبل', 'insert_row_after' => 'افزودن سطر به بعد', 'delete_row' => 'حذف سطر', @@ -148,7 +149,7 @@ return [ 'url' => 'آدرس', 'text_to_display' => 'متن جهت نمایش', 'title' => 'عنوان', - 'browse_links' => 'Browse links', + 'browse_links' => 'مرور پیوندها', 'open_link' => 'بازکردن لینک', 'open_link_in' => 'باز کردن لینک در ...', 'open_link_current' => 'پنجره کنونی', @@ -165,8 +166,8 @@ return [ 'about' => 'درباره ویرایشگر', 'about_title' => 'درباره ویرایشگر WYSIWYG', 'editor_license' => 'مجوز و حق کپی رایت ویرایشگر', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'این ویرایشگر بر پایه‌ی نسخه‌ای مشتق‌شده از «:lexicalLink» ساخته شده است که تحت مجوز MIT منتشر می‌شود.', + 'editor_lexical_license_link' => 'جزئیات کامل مجوز را می‌توانید این‌جا مشاهده کنید.', 'editor_tiny_license' => 'این ویرایشگر توسط :tinyLink و تحت مجوز MIT ساخته شده است.', 'editor_tiny_license_link' => 'جزئیات کپی رایت و مجوز TinyMCE را می توانید در اینجا پیدا کنید.', 'save_continue' => 'ذخیره صفحه و ادامه', diff --git a/lang/fa/entities.php b/lang/fa/entities.php index c9924a478..01db74a92 100644 --- a/lang/fa/entities.php +++ b/lang/fa/entities.php @@ -39,30 +39,30 @@ return [ 'export_pdf' => 'فایل PDF', 'export_text' => 'پرونده متنی ساده', 'export_md' => 'راهنما مارک‌دون', - '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:', + 'export_zip' => 'فایل فشرده‌ی قابل‌حمل (ZIP)', + 'default_template' => 'قالب پیش‌فرض صفحه', + 'default_template_explain' => 'قالبی برای صفحه تعیین کنید که به‌عنوان محتوای پیش‌فرض در تمام صفحاتی که در این مورد ایجاد می‌شوند، به‌کار رود. توجه داشته باشید این قالب تنها در صورتی اعمال می‌شود که سازندهٔ صفحه به صفحهٔ قالب انتخاب‌شده دسترسی نمایشی داشته باشد.', + 'default_template_select' => 'انتخاب صفحهٔ قالب', + 'import' => 'وارد کردن', + 'import_validate' => 'اعتبارسنجی آیتم‌های واردشده', + 'import_desc' => 'می‌توانید کتاب‌ها، فصل‌ها و صفحات را با استفاده از یک فایل فشرده (ZIP) که از همین سامانه یا نمونه‌ای دیگر استخراج شده، وارد کنید. برای ادامه، یک فایل ZIP انتخاب نمایید. پس از بارگذاری و اعتبارسنجی فایل، در مرحله بعد می‌توانید تنظیمات انتقال را انجام داده و انتقال را تأیید کنید.', + 'import_zip_select' => 'انتخاب فایل ZIP برای بارگذاری', + 'import_zip_validation_errors' => 'هنگام اعتبارسنجی فایل ZIP ارائه‌شده، خطاهایی شناسایی شد:', + 'import_pending' => 'ورودی‌های در انتظار انتقال', + 'import_pending_none' => 'هیچ انتقال ورودی آغاز نشده است.', + 'import_continue' => 'ادامه فرآیند انتقال ورودی', + 'import_continue_desc' => 'محتوای فایل ZIP بارگذاری‌شده را که قرار است وارد سامانه شود، مرور کنید. پس از اطمینان از صحت آن، انتقال را آغاز نمایید تا محتوا به این سامانه افزوده شود. توجه داشته باشید که پس از انتقال موفق، فایل ZIP بارگذاری‌شده به‌صورت خودکار حذف خواهد شد.', + 'import_details' => 'جزئیات انتقال ورودی', + 'import_run' => 'شروع فرایند انتقال ورودی', + 'import_size' => 'حجم فایل ZIP واردشده: :size', + 'import_uploaded_at' => 'زمان بارگذاری: :relativeTime', + 'import_uploaded_by' => 'بارگذاری شده توسط:', + 'import_location' => 'مکان انتقال', + 'import_location_desc' => 'برای محتوای واردشده، مقصدی انتخاب کنید. برای ایجاد محتوا در آن مقصد، داشتن مجوزهای لازم ضروری است.', + 'import_delete_confirm' => 'مطمئن هستید که می‌خواهید آیتم واردشده را حدف کنید؟', + 'import_delete_desc' => 'با انجام این کار، فایل ZIP واردشده حذف می‌شود و این عمل بازگشت‌ناپذیر است.', + 'import_errors' => 'خطای انتقال ورودی', + 'import_errors_desc' => 'در جریان تلاش برای انتقال ورودی، خطاهای زیر رخ داد:', // Permissions and restrictions 'permissions' => 'مجوزها', @@ -166,9 +166,9 @@ return [ 'books_search_this' => 'این کتاب را جستجو کنید', 'books_navigation' => 'ناوبری کتاب', 'books_sort' => 'مرتب سازی مطالب کتاب', - 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.', - 'books_sort_auto_sort' => 'Auto Sort Option', - 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', + 'books_sort_desc' => 'برای سامان‌دهی محتوای یک کتاب، می‌توانید فصل‌ها و صفحات آن را جابه‌جا کنید. همچنین می‌توانید کتاب‌های دیگری بیفزایید تا جابه‌جایی فصل‌ها و صفحات میان کتاب‌ها آسان شود. در صورت تمایل، می‌توانید قاعده‌ای برای مرتب‌سازی خودکار تعیین کنید تا محتوای کتاب در صورت ایجاد تغییرات، به طور خودکار مرتب شود.', + 'books_sort_auto_sort' => 'گزینه مرتب‌سازی خودکار', + 'books_sort_auto_sort_active' => 'مرتب‌سازی خودکار با قاعده: :sortName فعال است', 'books_sort_named' => 'مرتب‌سازی کتاب:bookName', 'books_sort_name' => 'مرتب سازی بر اساس نام', 'books_sort_created' => 'مرتب سازی بر اساس تاریخ ایجاد', @@ -230,7 +230,9 @@ return [ 'pages_delete_draft' => 'حذف صفحه پیش نویس', 'pages_delete_success' => 'صفحه حذف شد', 'pages_delete_draft_success' => 'صفحه پیش نویس حذف شد', - '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' => 'آیا مطمئن هستید که می خواهید این صفحه را حذف کنید؟', 'pages_delete_draft_confirm' => 'آیا مطمئن هستید که می خواهید این صفحه پیش نویس را حذف کنید؟', 'pages_editing_named' => 'ویرایش صفحه :pageName', @@ -247,8 +249,9 @@ 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 Beta Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'تغییر به ویرایشگر جدید WYSIWYG +(ویرایشگر WYSIWYG: «آنچه می‌بینید همان است که به‌دست می‌آورید»)', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(در مرحله آزمایش بتا)', 'pages_edit_set_changelog' => 'تنظیم تغییرات', 'pages_edit_enter_changelog_desc' => 'توضیح مختصری از تغییراتی که ایجاد کرده اید وارد کنید', 'pages_edit_enter_changelog' => 'وارد کردن تغییرات', @@ -268,6 +271,7 @@ return [ 'pages_md_insert_drawing' => 'درج طرح', 'pages_md_show_preview' => 'دیدن پیش نمایش', 'pages_md_sync_scroll' => 'هماهنگ سازی اسکرول پیش نمایش', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'نقاشی ذخیره نشده پیدا شد', 'pages_drawing_unsaved_confirm' => 'نسخه‌ای ذخیره‌نشده از طراحی‌های قبلی پیدا شد. آیا می‌خواهید این طراحی ذخیره‌نشده را بازیابی کنید و به ویرایش آن ادامه دهید؟', 'pages_not_in_chapter' => 'صفحه در یک فصل نیست', @@ -301,9 +305,9 @@ return [ 'pages_pointer_enter_mode' => 'ورود به حالت انتخاب قسمت', 'pages_pointer_label' => 'گزینه‌های قسمت صفحه', 'pages_pointer_permalink' => 'لینک ثابت قسمت صفحه', - 'pages_pointer_include_tag' => 'Page Section Include Tag', - 'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag', - 'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink', + 'pages_pointer_include_tag' => 'افزودن برچسب برای این بخش از صفحه', + 'pages_pointer_toggle_link' => 'حالت پیوند دائمی؛ برای مشاهده برچسب افزوده شده، کلیک کنید', + 'pages_pointer_toggle_include' => 'حالت افزودن برچسب؛ برای نمایش پیوند دائمی کلیک کنید', 'pages_permissions_active' => 'مجوزهای صفحه فعال است', 'pages_initial_revision' => 'انتشار اولیه', 'pages_references_update_revision' => 'به‌روز‌رسانی خودکار لینک‌های داخلی سیستم', @@ -324,7 +328,7 @@ return [ 'pages_is_template' => 'الگوی صفحه', // Editor Sidebar - 'toggle_sidebar' => 'Toggle Sidebar', + 'toggle_sidebar' => 'نمایش/پنهان‌سازی نوار کناری', 'page_tags' => 'برچسب‌های صفحه', 'chapter_tags' => 'برچسب‌های فصل', 'book_tags' => 'برچسب های کتاب', @@ -392,11 +396,11 @@ return [ 'comment' => 'اظهار نظر', 'comments' => 'نظرات', 'comment_add' => 'افزودن توضیح', - 'comment_none' => 'No comments to display', + 'comment_none' => 'نظری برای نمایش وجود ندارد', 'comment_placeholder' => 'اینجا نظر بدهید', - 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', - 'comment_archived_threads' => 'Archived Threads', + 'comment_thread_count' => ':count رشته گفت‌وگو', + 'comment_archived_count' => ':count مورد بایگانی‌شده', + 'comment_archived_threads' => 'رشته‌ گفت‌وگوهای بایگانی‌شده', 'comment_save' => 'ذخیره نظر', 'comment_new' => 'نظر جدید', 'comment_created' => ':createDiff نظر داد', @@ -405,14 +409,14 @@ return [ 'comment_deleted_success' => 'نظر حذف شد', 'comment_created_success' => 'نظر اضافه شد', 'comment_updated_success' => 'نظر به روز شد', - 'comment_archive_success' => 'Comment archived', - 'comment_unarchive_success' => 'Comment un-archived', - 'comment_view' => 'View comment', - 'comment_jump_to_thread' => 'Jump to thread', + 'comment_archive_success' => 'نظر بایگانی شد', + 'comment_unarchive_success' => 'نظر از بایگانی خارج شد', + 'comment_view' => 'دیدن نظر', + 'comment_jump_to_thread' => 'رفتن به رشته گفت‌وگو', 'comment_delete_confirm' => 'آیا مطمئن هستید که می خواهید این نظر را حذف کنید؟', 'comment_in_reply_to' => 'در پاسخ به :commentId', - 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', + 'comment_reference' => 'مرجع', + 'comment_reference_outdated' => '(نسخه قدیمی)', 'comment_editor_explain' => 'در اینجا نظراتی که در این صفحه گذاشته شده است، مشاهده می‌شود. نظرات را می‌توان در هنگام مشاهده صفحه ذخیره شده، اضافه و مدیریت کرد.', // Revision diff --git a/lang/fa/errors.php b/lang/fa/errors.php index c4bc6963a..9d5257fcf 100644 --- a/lang/fa/errors.php +++ b/lang/fa/errors.php @@ -18,10 +18,10 @@ return [ 'ldap_fail_anonymous' => 'دسترسی LDAP با استفاده از صحافی ناشناس انجام نشد', 'ldap_fail_authed' => 'دسترسی به LDAP با استفاده از جزئیات داده شده و رمز عبور انجام نشد', 'ldap_extension_not_installed' => 'افزونه PHP LDAP نصب نشده است', - 'ldap_cannot_connect' => 'اتصال به سرور LDAP امکان پذیر نیست، اتصال اولیه برقرار نشد', + 'ldap_cannot_connect' => 'اتصال به سرور LDAP امکان‌پذیر نیست، اتصال اولیه برقرار نشد', 'saml_already_logged_in' => 'قبلا وارد سیستم شده اید', 'saml_no_email_address' => 'آدرس داده ای برای این کاربر در داده های ارائه شده توسط سیستم احراز هویت خارجی یافت نشد', - 'saml_invalid_response_id' => 'درخواست از سیستم احراز هویت خارجی توسط فرایندی که توسط این نرم افزار آغاز شده است شناخته نمی شود. بازگشت به سیستم پس از ورود به سیستم می تواند باعث این مسئله شود.', + 'saml_invalid_response_id' => 'درخواست ارسال‌شده از سامانه احراز هویت خارجی، توسط فرآیند آغازشده از سوی این نرم‌افزار شناسایی نشد. ممکن است بازگشت به صفحه قبل پس از ورود، موجب ایجاد این مشکل شده باشد.', 'saml_fail_authed' => 'ورود به سیستم :system انجام نشد، سیستم مجوز موفقیت آمیز ارائه نکرد', 'oidc_already_logged_in' => 'قبلا وارد شده اید', 'oidc_no_email_address' => 'آدرس ایمیلی برای این کاربر در داده های ارائه شده توسط سیستم احراز هویت خارجی یافت نشد', @@ -43,7 +43,7 @@ return [ 'path_not_writable' => 'مسیر فایل :filePath را نمی توان در آن آپلود کرد. مطمئن شوید که روی سرور قابل نوشتن است.', 'cannot_get_image_from_url' => 'نمی توان تصویر را از :url دریافت کرد', 'cannot_create_thumbs' => 'سرور نمی تواند تصاویر کوچک ایجاد کند. لطفاً بررسی کنید که پسوند GD PHP را نصب کرده اید.', - 'server_upload_limit' => 'سرور اجازه آپلود در این اندازه را نمی دهد. لطفا اندازه فایل کوچکتر را امتحان کنید.', + 'server_upload_limit' => 'سرور اجازه آپلود با این حجم را نمی دهد. لطفا فایلی با حجم کم‌تر را امتحان کنید.', 'server_post_limit' => 'سرور نمی‌تواند داده مقادیر ارائه شده داده را دریافت کند. با مقدار کمتر و فایل کوچکتر دوباره امتحان کنید.', 'uploaded' => 'سرور اجازه آپلود در این اندازه را نمی دهد. لطفا اندازه فایل کوچکتر را امتحان کنید.', @@ -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' => 'این نقش قابل ویرایش نیست', @@ -106,16 +106,16 @@ return [ 'back_soon' => 'به زودی پشتیبان خواهد شد.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'امکان ایجاد کاربر وجود ندارد؛ زیرا ارسال ایمیل دعوت با خطا مواجه شد.', + 'import_zip_cant_decode_data' => 'محتوای data.json در فایل ZIP پیدا یا رمزگشایی نشد.', + 'import_zip_no_data' => 'داده‌های فایل ZIP فاقد محتوای کتاب، فصل یا صفحه مورد انتظار است.', + 'import_validation_failed' => 'اعتبارسنجی فایل ZIP واردشده با خطا مواجه شد:', + 'import_zip_failed_notification' => ' فایل ZIP وارد نشد.', + 'import_perms_books' => 'شما مجوز لازم برای ایجاد کتاب را ندارید.', + 'import_perms_chapters' => 'شما مجوز لازم برای ایجاد فصل را ندارید.', + 'import_perms_pages' => 'شما مجوز لازم برای ایجاد صفحه را ندارید.', + 'import_perms_images' => 'شما مجوز لازم برای ایجاد تصویر را ندارید.', + 'import_perms_attachments' => 'شما مجوز لازم برای ایجاد پیوست را ندارید.', // API errors 'api_no_authorization_found' => 'هیچ نشانه مجوزی در درخواست یافت نشد', diff --git a/lang/fa/notifications.php b/lang/fa/notifications.php index 12f4230a1..4595fd825 100644 --- a/lang/fa/notifications.php +++ b/lang/fa/notifications.php @@ -22,6 +22,6 @@ return [ 'action_view_comment' => 'مشاهده نظر', 'action_view_page' => 'مشاهده صفحه', - 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', + 'footer_reason' => 'این اعلان برای شما ارسال شده است، زیرا پیوند (:link) فعالیتی از این نوع را برای این مورد پوشش می‌دهد.', 'footer_reason_link' => 'تنظیمات اطلاع‌رسانی شما', ]; diff --git a/lang/fa/settings.php b/lang/fa/settings.php index 7909864f7..e4cb9018e 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -15,7 +15,7 @@ return [ // App Settings 'app_customization' => 'سفارشی‌سازی', 'app_features_security' => 'ویژگی‌ها و امنیت', - 'app_name' => 'نام نرم افزار', + 'app_name' => 'نام نرم‌افزار', 'app_name_desc' => 'این نام در هدر و در هر ایمیل ارسال شده توسط سیستم نشان داده شده است.', 'app_name_header' => 'نمایش نام در هدر', 'app_public_access' => 'دسترسی عمومی', @@ -75,34 +75,34 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'بدون محدودیت', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', - 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', - 'sorting_rules' => 'Sort Rules', - 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', - 'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', - 'sort_rule_create' => 'Create Sort Rule', - 'sort_rule_edit' => 'Edit Sort Rule', - 'sort_rule_delete' => 'Delete Sort Rule', - 'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.', - 'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?', - 'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?', - 'sort_rule_details' => 'Sort Rule Details', - 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.', - 'sort_rule_operations' => 'Sort Operations', - 'sort_rule_operations_desc' => 'Configure the sort actions to be performed by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom. Any changes made here will be applied to all assigned books upon save.', - 'sort_rule_available_operations' => 'Available Operations', - 'sort_rule_available_operations_empty' => 'No operations remaining', - 'sort_rule_configured_operations' => 'Configured Operations', - 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', - 'sort_rule_op_asc' => '(Asc)', - 'sort_rule_op_desc' => '(Desc)', - 'sort_rule_op_name' => 'Name - Alphabetical', - 'sort_rule_op_name_numeric' => 'Name - Numeric', - 'sort_rule_op_created_date' => 'Created Date', - 'sort_rule_op_updated_date' => 'Updated Date', - 'sort_rule_op_chapters_first' => 'Chapters First', - 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting' => 'مرتب‌سازی', + 'sorting_book_default' => 'مرتب‌سازی پیش‌فرض کتاب', + 'sorting_book_default_desc' => 'قانون پیش‌فرض مرتب‌سازی را برای کتاب‌های جدید انتخاب کنید. تغییر قانون بر ترتیب کتاب‌های موجود تأثیری ندارد و می‌تواند برای هر کتاب به‌صورت جداگانه تغییر یابد.', + 'sorting_rules' => 'قوانین مرتب‌سازی', + 'sorting_rules_desc' => 'این‌ها عملیات مرتب‌سازی از پیش تعریف‌شده‌ای هستند که می‌توانید آن‌ها را بر محتوای سیستم اعمال کنید.', + 'sort_rule_assigned_to_x_books' => 'اختصاص داده شده به :count کتاب', + 'sort_rule_create' => 'ایجاد قانون مرتب‌سازی', + 'sort_rule_edit' => 'ویرایش قانون مرتب‌سازی', + 'sort_rule_delete' => 'حذف قانون مرتب‌سازی', + 'sort_rule_delete_desc' => 'این قانون مرتب‌سازی را از سیستم حذف کنید. کتاب‌هایی که از این شیوه مرتب‌سازی استفاده می‌کنند، به حالت مرتب‌سازی دستی بازخواهند گشت.', + 'sort_rule_delete_warn_books' => 'در حال حاضر این قانون مرتب‌سازی برای :count کتاب به‌کار می‌رود. آیا مطمئن هستید که می‌خواهید آن را حذف کنید؟', + 'sort_rule_delete_warn_default' => 'این قانون مرتب‌سازی هم‌اکنون به عنوان پیش‌فرض کتاب‌ها تعیین شده است. آیا مطمئن هستید که می‌خواهید آن را حذف کنید؟', + 'sort_rule_details' => 'جزئیات قانون مرتب‌سازی', + 'sort_rule_details_desc' => 'برای این قانون مرتب‌سازی یک نام انتخاب کنید. این نام هنگام انتخاب شیوه مرتب‌سازی در فهرست‌ها نمایش داده خواهد شد.', + 'sort_rule_operations' => 'عملیات مرتب‌سازی', + 'sort_rule_operations_desc' => 'عملیات مرتب‌سازی را پیکربندی کنید. برای این منظور، آن‌ها را در فهرست عملیاتِ در دسترس جابه‌جا کنید تا ترتیب اجرای آن‌ها مشخص شود. هنگام استفاده، این عملیات به‌ترتیب از بالا به پایین اعمال خواهند شد. هر تغییری که در این بخش ایجاد کنید، پس از ذخیره، برای همه کتاب‌های اختصاص‌یافته اجرا می‌شود.', + 'sort_rule_available_operations' => 'عملیات موجود', + 'sort_rule_available_operations_empty' => 'عملیاتی باقی نمانده است', + 'sort_rule_configured_operations' => 'عملیات پیکربندی‌شده', + 'sort_rule_configured_operations_empty' => 'عملیات را از فهرست «عملیات موجود» حذف یا اضافه کنید', + 'sort_rule_op_asc' => '(صعودی)', + 'sort_rule_op_desc' => '(نزولی)', + 'sort_rule_op_name' => 'نام - الفبایی', + 'sort_rule_op_name_numeric' => 'نام - عددی', + 'sort_rule_op_created_date' => 'تاریخ ایجاد', + 'sort_rule_op_updated_date' => 'تاریخ به‌روزرسانی', + 'sort_rule_op_chapters_first' => 'ابتدا فصل‌ها', + 'sort_rule_op_chapters_last' => 'فصل‌ها در آخر', // Maintenance settings 'maint' => 'نگهداری', @@ -192,7 +192,7 @@ return [ 'role_access_api' => 'دسترسی به API سیستم', 'role_manage_settings' => 'تنظیمات برنامه را مدیریت کنید', 'role_export_content' => 'صادرات محتوا', - 'role_import_content' => 'Import content', + 'role_import_content' => 'وارد کردن محتوا', 'role_editor_change' => 'تغییر ویرایشگر صفحه', 'role_notifications' => 'دریافت و مدیریت اعلان‌ها', 'role_asset' => 'مجوزهای دارایی', diff --git a/lang/fa/validation.php b/lang/fa/validation.php index 10432471f..e1c69ece9 100644 --- a/lang/fa/validation.php +++ b/lang/fa/validation.php @@ -105,10 +105,10 @@ return [ 'url' => ':attribute معتبر نمی‌باشد.', 'uploaded' => 'بارگذاری فایل :attribute موفقیت آمیز نبود.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => 'ویژگی :attribute باید به یک فایل درون پرونده فشرده شده اشاره کند.', + 'zip_file_mime' => 'ویژگی :attribute باید به فایلی با نوع :validTypes اشاره کند، اما نوع یافت‌شده :foundType است.', + 'zip_model_expected' => 'سیستم در این بخش انتظار دریافت یک شیء داده‌ای را داشت، اما «:type» دریافت گردید', + 'zip_unique' => 'برای هر نوع شیء در فایل ZIP، مقدار ویژگی :attribute باید یکتا و بدون تکرار باشد.', // Custom validation lines 'custom' => [ diff --git a/lang/fi/editor.php b/lang/fi/editor.php index 6b5683d38..8b72b4e5e 100644 --- a/lang/fi/editor.php +++ b/lang/fi/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Yläindeksi', 'subscript' => 'Alaindeksi', 'text_color' => 'Tekstin väri', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Mukautettu väri', 'remove_color' => 'Poista väri', 'background_color' => 'Taustaväri', diff --git a/lang/fi/entities.php b/lang/fi/entities.php index 3183abe5d..e6dad19e8 100644 --- a/lang/fi/entities.php +++ b/lang/fi/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Lisää piirustus', 'pages_md_show_preview' => 'Näytä esikatselu', 'pages_md_sync_scroll' => 'Vieritä esikatselua koodin vierityksen mukaan', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Tallentamaton piirustus löytyi', 'pages_drawing_unsaved_confirm' => 'Järjestelmä löysi tallentamattoman piirustuksen. Haluatko palauttaa piirustuksen ja jatkaa sen muokkaamista?', 'pages_not_in_chapter' => 'Sivu ei kuulu mihinkään lukuun', diff --git a/lang/fr/editor.php b/lang/fr/editor.php index fa620c520..8c6435424 100644 --- a/lang/fr/editor.php +++ b/lang/fr/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Exposant', 'subscript' => 'Indice', 'text_color' => 'Couleur Texte', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Couleur personnalisée', 'remove_color' => 'Supprimer la couleur', 'background_color' => 'Couleur d\'arrière-plan', diff --git a/lang/fr/entities.php b/lang/fr/entities.php index f538307ae..02b53e80b 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insérer un dessin', 'pages_md_show_preview' => 'Prévisualisation', 'pages_md_sync_scroll' => 'Défilement prévisualisation', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Dessin non enregistré trouvé', 'pages_drawing_unsaved_confirm' => 'Des données de dessin non enregistrées ont été trouvées à partir d\'une tentative de sauvegarde de dessin échouée. Voulez-vous restaurer et continuer à modifier ce dessin non sauvegardé ?', 'pages_not_in_chapter' => 'La page n\'est pas dans un chapitre', diff --git a/lang/he/editor.php b/lang/he/editor.php index f119041b6..6390389b8 100644 --- a/lang/he/editor.php +++ b/lang/he/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'כתב עילי', 'subscript' => 'כתב תחתי', 'text_color' => 'צבע טקסט', + 'highlight_color' => 'Highlight color', 'custom_color' => 'צבע מותאם', 'remove_color' => 'הסר צבע', 'background_color' => 'צבע רקע', diff --git a/lang/he/entities.php b/lang/he/entities.php index ce1d4fa80..495e2c66a 100644 --- a/lang/he/entities.php +++ b/lang/he/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'הכנס סרטוט', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'דף אינו חלק מפרק', diff --git a/lang/hr/editor.php b/lang/hr/editor.php index 274f41c84..4ac908318 100644 --- a/lang/hr/editor.php +++ b/lang/hr/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Gornji indeks', 'subscript' => 'Donji indeks', 'text_color' => 'Boja teksta', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Prilagođena boja', 'remove_color' => 'Ukloni boju', 'background_color' => 'Boja pozadine', diff --git a/lang/hr/entities.php b/lang/hr/entities.php index 62918c367..71f591b0f 100644 --- a/lang/hr/entities.php +++ b/lang/hr/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Umetni crtež', 'pages_md_show_preview' => 'Prikaži pregled', 'pages_md_sync_scroll' => 'Sinkroniziraj pomicanje pregleda', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Pronađen je Nespremljen Crtež', 'pages_drawing_unsaved_confirm' => 'Pronađeni su nespremljeni podaci crteža iz prethodnog neuspjelog pokušaja spremanja crteža. Želite li obnoviti i nastaviti uređivati ovaj nespremljeni crtež?', 'pages_not_in_chapter' => 'Stranica nije u poglavlju', diff --git a/lang/hu/editor.php b/lang/hu/editor.php index d5c6baed6..705bc6d54 100644 --- a/lang/hu/editor.php +++ b/lang/hu/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Felső index', 'subscript' => 'Alsó index', 'text_color' => 'Szöveg szín', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Egyéni szín', 'remove_color' => 'Szín eltávolítása', 'background_color' => 'Háttérszín', diff --git a/lang/hu/entities.php b/lang/hu/entities.php index 8686908f0..1af563d5e 100644 --- a/lang/hu/entities.php +++ b/lang/hu/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Rajz beillesztése', 'pages_md_show_preview' => 'Előnézet megjelenítése', 'pages_md_sync_scroll' => 'Előnézet pozíció szinkronizálása', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Nem mentett rajz található', 'pages_drawing_unsaved_confirm' => 'A rendszer nem mentett rajzadatokat talált egy korábbi sikertelen rajzmentési kísérletből. Szeretné visszaállítani és folytatni a nem mentett rajz szerkesztését?', 'pages_not_in_chapter' => 'Az oldal nincs fejezetben', diff --git a/lang/id/activities.php b/lang/id/activities.php index 380236439..6e1583a94 100644 --- a/lang/id/activities.php +++ b/lang/id/activities.php @@ -72,7 +72,7 @@ return [ 'mfa_remove_method_notification' => 'Metode multi-faktor sukses dihapus', // Settings - 'settings_update' => 'updated settings', + 'settings_update' => 'memperbarui setelan', 'settings_update_notification' => 'Pengaturan berhasil diperbarui', 'maintenance_action_run' => 'menjalankan tindakan pemeliharaan', @@ -85,12 +85,12 @@ return [ 'webhook_delete_notification' => 'Webhook berhasil dihapus', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => 'telat membuat impor', + 'import_create_notification' => 'Impor berhasil diunggah', + 'import_run' => 'telah memperbarui impor', + 'import_run_notification' => 'Konten berhasil diimpor', + 'import_delete' => 'telah menghapus impor', + 'import_delete_notification' => 'Impor berhasil dihapus', // Users 'user_create' => 'pengguna yang dibuat', @@ -109,31 +109,31 @@ return [ 'api_token_delete_notification' => 'token API berhasil dihapus ', // Roles - 'role_create' => 'created role', + 'role_create' => 'telah membuat peran', 'role_create_notification' => 'Peran berhasil dibuat', - 'role_update' => 'updated role', + 'role_update' => 'telah memperbarui peran', 'role_update_notification' => 'Peran berhasil diperbarui', - 'role_delete' => 'deleted role', + 'role_delete' => 'telah menghapus peran', 'role_delete_notification' => 'Peran berhasil dihapus', // 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' => 'telah mengosongkan tempat sampah', + 'recycle_bin_restore' => 'telah mengembalikan dari tempat sampah', + 'recycle_bin_destroy' => 'telah menghapus dari tempat sampah', // Comments 'commented_on' => 'berkomentar pada', - 'comment_create' => 'added comment', - 'comment_update' => 'updated comment', - 'comment_delete' => 'deleted comment', + 'comment_create' => 'telah menambah komentar', + 'comment_update' => 'telah memperbarui komentar', + 'comment_delete' => 'telah menghapus komentar', // Sort Rules - 'sort_rule_create' => 'created sort rule', - 'sort_rule_create_notification' => 'Sort rule successfully created', - 'sort_rule_update' => 'updated sort rule', - 'sort_rule_update_notification' => 'Sort rule successfully updated', - 'sort_rule_delete' => 'deleted sort rule', - 'sort_rule_delete_notification' => 'Sort rule successfully deleted', + 'sort_rule_create' => 'telah membuat aturan penyortiran', + 'sort_rule_create_notification' => 'Aturan penyortiran berhasil dibuat', + 'sort_rule_update' => 'telah mengubah aturan penyortiran', + 'sort_rule_update_notification' => 'Aturan penyortiran berhasil diubah', + 'sort_rule_delete' => 'telah menghapus aturan penyortiran', + 'sort_rule_delete_notification' => 'Aturan penyortiran berhasil dihapus', // Other 'permissions_update' => 'izin diperbarui', diff --git a/lang/id/auth.php b/lang/id/auth.php index 552742bc6..6af60ca80 100644 --- a/lang/id/auth.php +++ b/lang/id/auth.php @@ -86,32 +86,32 @@ return [ 'mfa_setup_configured' => 'Sudah dikonfigurasi', 'mfa_setup_reconfigure' => 'Konfigurasi ulang', 'mfa_setup_remove_confirmation' => 'Apakah Anda yakin ingin menghapus metode autentikasi multi-faktor ini?', - '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_setup_action' => 'Atur', + 'mfa_backup_codes_usage_limit_warning' => 'Anda memiliki kurang dari 5 kode cadangan yang tersisa. Harap buat dan simpan set baru sebelum Anda kehabisan kode untuk mencegah akun Anda terkunci.', 'mfa_option_totp_title' => 'Aplikasi Seluler', - '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_totp_desc' => 'Untuk menggunakan autentikasi multi-faktor, Anda memerlukan aplikasi seluler yang mendukung TOTP seperti Google Authenticator, Authy, atau Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Kode Cadangan', - '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_option_backup_codes_desc' => 'Menghasilkan serangkaian kode cadangan sekali pakai yang akan Anda masukkan saat masuk untuk memverifikasi identitas Anda. Pastikan untuk menyimpannya di tempat yang aman.', + 'mfa_gen_confirm_and_enable' => 'Konfirmasi dan Aktifkan', + 'mfa_gen_backup_codes_title' => 'Pengaturan Kode Cadangan', + 'mfa_gen_backup_codes_desc' => 'Simpan daftar kode di bawah ini di tempat yang aman. Saat mengakses sistem, Anda dapat menggunakan salah satu kode sebagai mekanisme autentikasi kedua.', + 'mfa_gen_backup_codes_download' => 'Unduh Kode', + 'mfa_gen_backup_codes_usage_warning' => 'Setiap kode hanya dapat digunakan satu kali', + 'mfa_gen_totp_title' => 'Pengaturan Aplikasi Seluler', + 'mfa_gen_totp_desc' => 'Untuk menggunakan autentikasi multi-faktor, Anda memerlukan aplikasi seluler yang mendukung TOTP seperti Google Authenticator, Authy, atau Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Pindai kode QR di bawah ini menggunakan aplikasi autentikasi pilihan Anda untuk memulai.', + 'mfa_gen_totp_verify_setup' => 'Verifikasi Pengaturan', + 'mfa_gen_totp_verify_setup_desc' => 'Verifikasi bahwa semuanya berfungsi dengan memasukkan kode yang dibuat dalam aplikasi autentikasi Anda pada kolom input di bawah ini:', + 'mfa_gen_totp_provide_code_here' => 'Berikan kode yang dihasilkan aplikasi Anda di sini', + 'mfa_verify_access' => 'Verifikasi Akses', + 'mfa_verify_access_desc' => 'Akun pengguna Anda mengharuskan Anda mengonfirmasi identitas Anda melalui tingkat verifikasi tambahan sebelum Anda diberikan akses. Verifikasi menggunakan salah satu metode yang telah Anda konfigurasikan untuk melanjutkan.', + 'mfa_verify_no_methods' => 'Tidak Ada Metode yang Dikonfigurasi', + 'mfa_verify_no_methods_desc' => 'Tidak ada metode autentikasi multi-faktor yang ditemukan untuk akun Anda. Anda perlu menyiapkan setidaknya satu metode sebelum mendapatkan akses.', 'mfa_verify_use_totp' => 'Verifikasi menggunakan aplikasi seluler', 'mfa_verify_use_backup_codes' => 'Verifikasi menggunakan kode cadangan', 'mfa_verify_backup_code' => 'Kode Cadangan', - '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.', + 'mfa_verify_backup_code_desc' => 'Masukkan salah satu kode cadangan Anda yang tersisa di bawah ini:', + 'mfa_verify_backup_code_enter_here' => 'Masukkan kode cadangan di sini', + 'mfa_verify_totp_desc' => 'Masukkan kode yang dibuat menggunakan aplikasi seluler Anda di bawah ini:', + 'mfa_setup_login_notification' => 'Metode multi-faktor dikonfigurasi. Silakan masuk lagi menggunakan metode yang dikonfigurasi.', ]; diff --git a/lang/id/common.php b/lang/id/common.php index f6520420f..cee2d327f 100644 --- a/lang/id/common.php +++ b/lang/id/common.php @@ -6,7 +6,7 @@ return [ // Buttons 'cancel' => 'Batal', - 'close' => 'Close', + 'close' => 'Tutup', 'confirm' => 'Konfirmasi', 'back' => 'Kembali', 'save' => 'Simpan', @@ -20,18 +20,18 @@ return [ 'description' => 'Deskripsi', 'role' => 'Peran', 'cover_image' => 'Sampul gambar', - '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' => 'Gambar ini harus berukuran sekitar 440x250px walaupun nanti akan disesuaikan & terpotong secara fleksibel agar sesuai dengan tampilan pengguna, sehingga dimensi sebenarnya untuk tampilan akan berbeda.', // Actions 'actions' => 'Tindakan', 'view' => 'Lihat', 'view_all' => 'Lihat Semua', - 'new' => 'New', + 'new' => 'Buat Baru', 'create' => 'Buat', 'update' => 'Perbarui', 'edit' => 'Sunting', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => 'Buat Arsip', + 'unarchive' => 'Batalkan Arsip', 'sort' => 'Sortir', 'move' => 'Pindahkan', 'copy' => 'Salin', @@ -43,18 +43,18 @@ return [ 'reset' => 'Atur ulang', 'remove' => 'Hapus', 'add' => 'Tambah', - 'configure' => 'Configure', - 'manage' => 'Manage', + 'configure' => 'Atur', + 'manage' => 'Kelola', 'fullscreen' => 'Layar Penuh', 'favourite' => 'Favorit', 'unfavourite' => 'Batal favorit', 'next' => 'Selanjutnya', 'previous' => 'Sebelumnya', - 'filter_active' => 'Active Filter:', - 'filter_clear' => 'Clear Filter', - 'download' => 'Download', - 'open_in_tab' => 'Open in Tab', - 'open' => 'Open', + 'filter_active' => 'Filter Aktif:', + 'filter_clear' => 'Hapus Filter', + 'download' => 'Unduh', + 'open_in_tab' => 'Buka di tab baru', + 'open' => 'Buka', // Sort Options 'sort_options' => 'Opsi Sortir', @@ -80,20 +80,20 @@ return [ 'default' => 'Bawaan', 'breadcrumb' => 'Breadcrumb', 'status' => 'Status', - 'status_active' => 'Active', - 'status_inactive' => 'Inactive', - 'never' => 'Never', - 'none' => 'None', + 'status_active' => 'Aktif', + 'status_inactive' => 'Tidak Aktif', + 'never' => 'Jangan Pernah', + 'none' => 'Tidak Satupun', // Header - 'homepage' => 'Homepage', + 'homepage' => 'Beranda', 'header_menu_expand' => 'Perluas Menu Tajuk', 'profile_menu' => 'Menu Profil', 'view_profile' => 'Tampilkan Profil', 'edit_profile' => 'Sunting Profil', 'dark_mode' => 'Mode Gelap', 'light_mode' => 'Mode Terang', - 'global_search' => 'Global Search', + 'global_search' => 'Pencarian Global', // Layout tabs 'tab_info' => 'Informasi', @@ -111,5 +111,5 @@ return [ 'terms_of_service' => 'Ketentuan Layanan', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Cari :appName', ]; diff --git a/lang/id/components.php b/lang/id/components.php index 373d6ace5..45aa72fba 100644 --- a/lang/id/components.php +++ b/lang/id/components.php @@ -6,36 +6,36 @@ return [ // Image Manager 'image_select' => 'Pilih Gambar', - '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' => 'Daftar Gambar', + 'image_details' => 'Detail Gambar', + 'image_upload' => 'Unggah Gambar', + 'image_intro' => 'Di sini Anda dapat memilih dan mengelola gambar yang sebelumnya diunggah ke sistem.', + 'image_intro_upload' => 'Unggah gambar baru dengan menyeret berkas gambar ke jendela ini, atau dengan menggunakan tombol "Unggah Gambar" di atas.', 'image_all' => 'Semua', 'image_all_title' => 'Lihat semua gambar', 'image_book_title' => 'Lihat gambar yang diunggah ke buku ini', 'image_page_title' => 'Lihat gambar yang diunggah ke halaman ini', 'image_search_hint' => 'Cari berdasarkan nama gambar', 'image_uploaded' => 'Diunggah :uploadedDate', - 'image_uploaded_by' => 'Uploaded by :userName', - 'image_uploaded_to' => 'Uploaded to :pageLink', - 'image_updated' => 'Updated :updateDate', + 'image_uploaded_by' => 'Diunggah oleh :userName', + 'image_uploaded_to' => 'Diunggah ke :pageLink', + 'image_updated' => 'Diperbarui :updateDate', 'image_load_more' => 'Muat lebih banyak', 'image_image_name' => 'Muat lebih banyak', 'image_delete_used' => 'Gambar ini digunakan untuk halaman dibawah ini.', 'image_delete_confirm_text' => 'Anda yakin ingin menghapus gambar ini?', 'image_select_image' => 'Pilih gambar', 'image_dropzone' => 'Lepaskan gambar atau klik di sini untuk mengunggah', - 'image_dropzone_drop' => 'Drop images here to upload', + 'image_dropzone_drop' => 'Letakkan gambar di sini untuk diunggah', 'images_deleted' => 'Gambar Dihapus', 'image_preview' => 'Pratinjau Gambar', 'image_upload_success' => 'Gambar berhasil diunggah', 'image_update_success' => 'Detail gambar berhasil diperbarui', 'image_delete_success' => 'Gambar berhasil dihapus', - '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' => 'Ganti Gambar', + 'image_replace_success' => 'Berkas gambar berhasil diperbarui', + 'image_rebuild_thumbs' => 'Buat Ulang Variasi Ukuran', + 'image_rebuild_thumbs_success' => 'Variasi ukuran gambar berhasil dibuat ulang!', // Code Editor 'code_editor' => 'Edit Kode', diff --git a/lang/id/editor.php b/lang/id/editor.php index 40fc4a5ab..049993689 100644 --- a/lang/id/editor.php +++ b/lang/id/editor.php @@ -13,7 +13,7 @@ return [ 'cancel' => 'Batal', 'save' => 'Simpan', 'close' => 'Tutup', - 'apply' => 'Apply', + 'apply' => 'Terapkan', 'undo' => 'Undo', 'redo' => 'Ulangi', 'left' => 'Kiri', @@ -29,69 +29,70 @@ return [ // Toolbar 'formats' => 'Format', - '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', + 'header_large' => 'Header Besar', + 'header_medium' => 'Header Sedang', + 'header_small' => 'Header Kecil', + 'header_tiny' => 'Header Sangat Kecil', + 'paragraph' => 'Paragraf', + 'blockquote' => 'Kutipan blok', + 'inline_code' => 'Kode sebaris', + 'callouts' => 'Anotasi', + 'callout_information' => 'Informasi', 'callout_success' => 'Sukses', 'callout_warning' => 'Peringatan', 'callout_danger' => 'Bahaya', 'bold' => 'Berani', 'italic' => 'Italic', 'underline' => 'Garis Bawah', - 'strikethrough' => 'Strikethrough', - 'superscript' => 'Superscript', + 'strikethrough' => 'Coret', + 'superscript' => 'Superskrip', 'subscript' => 'Berlangganan', 'text_color' => 'Warna teks', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Warna khusus', 'remove_color' => 'Hapus Warna', 'background_color' => 'Warna latar', 'align_left' => 'Rata Kiri', - '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', + 'align_center' => 'Rata tengah', + 'align_right' => 'Rata kanan', + 'align_justify' => 'Rata kanan kiri', + 'list_bullet' => 'Daftar poin', + 'list_numbered' => 'Daftar bernomor', + 'list_task' => 'Daftar tugas', + 'indent_increase' => 'Tambah indentasi', + 'indent_decrease' => 'Kurangi indentasi', + 'table' => 'Tabel', + 'insert_image' => 'Sisipkan gambar', + 'insert_image_title' => 'Sisipkan/Ubah Gambar', + 'insert_link' => 'Sisipkan/ubah tautan', + 'insert_link_title' => 'Sisipkan/Ubah Tautan', + 'insert_horizontal_line' => 'Sisipkan garis horizontal', + 'insert_code_block' => 'Sisipkan blok kode', + 'edit_code_block' => 'Ubah blok kode', + 'insert_drawing' => 'Sisipkan/ubah gambaran', + 'drawing_manager' => 'Manajer Gambaran', + 'insert_media' => 'Sisipkan/ubah media', + 'insert_media_title' => 'Sisipkan/Ubah Media', + 'clear_formatting' => 'Bersihkan format', + 'source_code' => 'Kode sumber', + 'source_code_title' => 'Kode Sumber', + 'fullscreen' => 'Layar penuh', + 'image_options' => 'Opsi gambar', // 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_properties' => 'Properti tabel', + 'table_properties_title' => 'Properti Tabel', + 'delete_table' => 'Hapus tabel', + 'table_clear_formatting' => 'Bersihkan format tabel', + 'resize_to_contents' => 'Sesuaikan dengan ukuran konten', + 'row_header' => 'Judul baris', + 'insert_row_before' => 'Sisipkan baris sebelum', + 'insert_row_after' => 'Sisipkan baris setelah', + 'delete_row' => 'Hapus baris', + 'insert_column_before' => 'Sisipkan kolom sebelum', + 'insert_column_after' => 'Sisipkan kolom sesudah', + 'delete_column' => 'Hapus kolom', + 'table_cell' => 'Sel', 'table_row' => 'Row', 'table_column' => 'Column', 'cell_properties' => 'Cell properties', diff --git a/lang/id/entities.php b/lang/id/entities.php index 7fd76f449..6cd88b51b 100644 --- a/lang/id/entities.php +++ b/lang/id/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Sisipkan Gambar', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Halaman tidak dalam satu bab', diff --git a/lang/id/errors.php b/lang/id/errors.php index b4e2c6feb..6f7661738 100644 --- a/lang/id/errors.php +++ b/lang/id/errors.php @@ -10,7 +10,7 @@ return [ // Auth 'error_user_exists_different_creds' => 'Pengguna dengan email :email sudah ada tetapi dengan kredensial berbeda.', - 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', + 'auth_pre_register_theme_prevention' => 'Akun pengguna tidak dapat didaftarkan untuk rincian yang diberikan', 'email_already_confirmed' => 'Email telah dikonfirmasi, Coba masuk.', 'email_confirmation_invalid' => 'Token konfirmasi ini tidak valid atau telah digunakan, Silakan coba mendaftar lagi.', 'email_confirmation_expired' => 'Token konfirmasi telah kedaluwarsa, Email konfirmasi baru telah dikirim.', @@ -23,9 +23,9 @@ return [ 'saml_no_email_address' => 'Tidak dapat menemukan sebuah alamat email untuk pengguna ini, dalam data yang diberikan oleh sistem autentikasi eksternal', 'saml_invalid_response_id' => 'Permintaan dari sistem otentikasi eksternal tidak dikenali oleh sebuah proses yang dimulai oleh aplikasi ini. Menavigasi kembali setelah masuk dapat menyebabkan masalah ini.', 'saml_fail_authed' => 'Masuk menggunakan :system gagal, sistem tidak memberikan otorisasi yang berhasil', - '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', + 'oidc_already_logged_in' => 'Sudah masuk', + 'oidc_no_email_address' => 'Tidak dapat menemukan alamat email untuk pengguna ini dalam data yang diberikan oleh sistem autentikasi eksternal', + 'oidc_fail_authed' => 'Masuk menggunakan :system gagal, sistem tidak memberikan otorisasi yang berhasil', 'social_no_action_defined' => 'Tidak ada tindakan yang ditentukan', 'social_login_bad_response' => "Kesalahan yang diterima selama masuk menggunakan :socialAccount : \n:error", 'social_account_in_use' => 'Akun :socialAccount ini sudah digunakan, Coba masuk melalui opsi :socialAccount.', @@ -37,37 +37,37 @@ return [ 'social_driver_not_found' => 'Pengemudi sosial tidak ditemukan', 'social_driver_not_configured' => 'Pengaturan sosial :socialAccount Anda tidak dikonfigurasi dengan benar.', 'invite_token_expired' => 'Tautan undangan ini telah kedaluwarsa. Sebagai gantinya, Anda dapat mencoba mengatur ulang kata sandi akun Anda.', - 'login_user_not_found' => 'A user for this action could not be found.', + 'login_user_not_found' => 'Pengguna untuk tindakan ini tidak dapat ditemukan.', // System 'path_not_writable' => 'Jalur berkas :filePath tidak dapat diunggah. Pastikan berkas tersebut dapat ditulis ke server.', 'cannot_get_image_from_url' => 'Tidak dapat mengambil gambar dari :url', 'cannot_create_thumbs' => 'Server tidak dapat membuat thumbnail. Harap periksa apakah Anda telah memasang ekstensi GD PHP.', 'server_upload_limit' => 'Server tidak mengizinkan unggahan dengan ukuran ini. Harap coba ukuran berkas yang lebih kecil.', - 'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.', + 'server_post_limit' => 'Server tidak dapat menerima jumlah data yang diberikan. Coba lagi dengan data yang lebih sedikit atau berkas yang lebih kecil.', 'uploaded' => 'Server tidak mengizinkan unggahan dengan ukuran ini. Harap coba ukuran berkas yang lebih kecil.', // Drawing & Images 'image_upload_error' => 'Terjadi kesalahan saat mengunggah gambar', 'image_upload_type_error' => 'Jenis gambar yang diunggah tidak valid', - 'image_upload_replace_type' => 'Image file replacements must be of the same type', - 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.', - 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.', - 'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.', - 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.', + 'image_upload_replace_type' => 'Penggantian file gambar harus berjenis sama', + 'image_upload_memory_limit' => 'Gagal menangani pengunggahan gambar dan/atau membuat thumbnail karena keterbatasan sumber daya sistem.', + 'image_thumbnail_memory_limit' => 'Gagal membuat variasi ukuran gambar karena keterbatasan sumber daya sistem.', + 'image_gallery_thumbnail_memory_limit' => 'Gagal membuat thumbnail galeri karena keterbatasan sumber daya sistem.', + 'drawing_data_not_found' => 'Data gambar tidak dapat dimuat. Berkas gambar mungkin sudah tidak ada atau Anda mungkin tidak memiliki izin untuk mengaksesnya.', // Attachments 'attachment_not_found' => 'Lampiran tidak ditemukan', - 'attachment_upload_error' => 'An error occurred uploading the attachment file', + 'attachment_upload_error' => 'Terjadi kesalahan saat mengunggah berkas', // Pages 'page_draft_autosave_fail' => 'Gagal menyimpan draf. Pastikan Anda memiliki koneksi internet sebelum menyimpan halaman ini', - 'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content', + 'page_draft_delete_fail' => 'Gagal menghapus draf halaman dan mengambil konten tersimpan halaman saat ini', 'page_custom_home_deletion' => 'Tidak dapat menghapus sebuah halaman saat diatur sebagai sebuah halaman beranda', // Entities 'entity_not_found' => 'Entitas tidak ditemukan', - 'bookshelf_not_found' => 'Shelf not found', + 'bookshelf_not_found' => 'Rak tidak ditemukan', 'book_not_found' => 'Buku tidak ditemukan', 'page_not_found' => 'Halaman tidak ditemukan', 'chapter_not_found' => 'Bab tidak ditemukan', @@ -78,7 +78,7 @@ return [ // Users 'users_cannot_delete_only_admin' => 'Anda tidak dapat menghapus satu-satunya admin', 'users_cannot_delete_guest' => 'Anda tidak dapat menghapus pengguna tamu', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Tidak dapat membuat pengguna karena email undangan gagal dikirim', // Roles 'role_cannot_be_edited' => 'Peran ini tidak dapat disunting', @@ -106,16 +106,16 @@ return [ 'back_soon' => 'Ini akan segera kembali.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'Tidak dapat membaca berkas ZIP.', + 'import_zip_cant_decode_data' => 'Tidak dapat menemukan dan mendekode konten ZIP data.json.', + 'import_zip_no_data' => 'Data berkas ZIP tidak berisi konten buku, bab, atau halaman yang diharapkan.', + 'import_validation_failed' => 'Impor ZIP gagal divalidasi dengan kesalahan:', + 'import_zip_failed_notification' => 'Gagal mengimpor berkas ZIP.', + 'import_perms_books' => 'Anda tidak memiliki izin yang diperlukan untuk membuat buku.', + 'import_perms_chapters' => 'Anda tidak memiliki izin yang diperlukan untuk membuat bab.', + 'import_perms_pages' => 'Anda tidak memiliki izin yang diperlukan untuk membuat halaman.', + 'import_perms_images' => 'Anda tidak memiliki izin yang diperlukan untuk membuat gambar.', + 'import_perms_attachments' => 'Anda tidak memiliki izin yang diperlukan untuk membuat lampiran.', // API errors 'api_no_authorization_found' => 'Tidak ada token otorisasi yang ditemukan pada permintaan tersebut', @@ -129,5 +129,5 @@ return [ 'maintenance_test_email_failure' => 'Kesalahan dilempar saat mengirim email uji:', // HTTP errors - 'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts', + 'http_ssr_url_no_match' => 'URL tidak cocok dengan host SSR yang diizinkan yang dikonfigurasi', ]; diff --git a/lang/id/notifications.php b/lang/id/notifications.php index 1afd23f1d..b22af1f73 100644 --- a/lang/id/notifications.php +++ b/lang/id/notifications.php @@ -4,24 +4,24 @@ */ 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.', + 'new_comment_subject' => 'Komentar baru di halaman: :pageName', + 'new_comment_intro' => 'Seorang pengguna telah mengomentari halaman di :appName:', + 'new_page_subject' => 'Halaman baru: :pageName', + 'new_page_intro' => 'Halaman baru telah dibuat di :appName:', + 'updated_page_subject' => 'Halaman yang diperbarui: :pageName', + 'updated_page_intro' => 'Halaman telah diperbarui di :appName:', + 'updated_page_debounce' => 'Untuk mencegah banyaknya pemberitahuan, untuk sementara Anda tidak akan dikirimi pemberitahuan untuk pengeditan lebih lanjut pada halaman ini oleh editor yang sama.', - '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:', + 'detail_page_name' => 'Nama Halaman:', + 'detail_page_path' => 'Jalur Halaman:', + 'detail_commenter' => 'Komentator:', + 'detail_comment' => 'Komentar:', + 'detail_created_by' => 'Dibuat Oleh:', + 'detail_updated_by' => 'Diperbarui Oleh:', - 'action_view_comment' => 'View Comment', - 'action_view_page' => 'View Page', + 'action_view_comment' => 'Lihat Komentar', + 'action_view_page' => 'Lihat Halaman', - 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', - 'footer_reason_link' => 'your notification preferences', + 'footer_reason' => 'Pemberitahuan ini dikirimkan kepada Anda karena :link mencakup jenis aktivitas untuk item ini.', + 'footer_reason_link' => 'preferensi notifikasi Anda', ]; diff --git a/lang/is/editor.php b/lang/is/editor.php index 95b8c54e6..2c4a24616 100644 --- a/lang/is/editor.php +++ b/lang/is/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Háletur', 'subscript' => 'Lágletur', 'text_color' => 'Litur texta', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Sérsniðinn litur', 'remove_color' => 'Fjarlægja lit', 'background_color' => 'Bakgrunnslitur', diff --git a/lang/is/entities.php b/lang/is/entities.php index 931f53ac6..917610027 100644 --- a/lang/is/entities.php +++ b/lang/is/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Setja inn teikningu', 'pages_md_show_preview' => 'Sýna forskoðun', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Síðan tilheyrir engum kafla', diff --git a/lang/it/editor.php b/lang/it/editor.php index 3fb517ffb..477340197 100644 --- a/lang/it/editor.php +++ b/lang/it/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Apice', 'subscript' => 'Pedice', 'text_color' => 'Colore del testo', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Colore personalizzato', 'remove_color' => 'Rimuovi colore', 'background_color' => 'Colore di sfondo', diff --git a/lang/it/entities.php b/lang/it/entities.php index 34a6a1015..e4d8eb9d4 100644 --- a/lang/it/entities.php +++ b/lang/it/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Inserisci disegno', 'pages_md_show_preview' => 'Visualizza anteprima', 'pages_md_sync_scroll' => 'Sincronizza scorrimento anteprima', + 'pages_md_plain_editor' => 'Editor di testo semplice', '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', diff --git a/lang/ja/editor.php b/lang/ja/editor.php index 6d4d8506e..e5bf6af8e 100644 --- a/lang/ja/editor.php +++ b/lang/ja/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => '上付き', 'subscript' => '下付き', 'text_color' => 'テキストの色', + 'highlight_color' => 'Highlight color', 'custom_color' => 'カスタムカラー', 'remove_color' => '色設定を解除', 'background_color' => '背景色', diff --git a/lang/ja/entities.php b/lang/ja/entities.php index cdb04b7b0..f0f86d611 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => '図を追加', 'pages_md_show_preview' => 'プレビューを表示', 'pages_md_sync_scroll' => 'プレビューとスクロールを同期', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => '未保存の図が見つかりました', 'pages_drawing_unsaved_confirm' => '以前に保存操作が失敗した、未保存の図が見つかりました。 未保存の図面を復元して編集を続けますか?', diff --git a/lang/ka/editor.php b/lang/ka/editor.php index 752c6f3f7..0d250e9a7 100644 --- a/lang/ka/editor.php +++ b/lang/ka/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Text color', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Custom color', 'remove_color' => 'Remove color', 'background_color' => 'Background color', diff --git a/lang/ka/entities.php b/lang/ka/entities.php index 561022ad6..ef625a3d2 100644 --- a/lang/ka/entities.php +++ b/lang/ka/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insert Drawing', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Page is not in a chapter', diff --git a/lang/ko/editor.php b/lang/ko/editor.php index ac4d8d53d..b5c4ae807 100644 --- a/lang/ko/editor.php +++ b/lang/ko/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => '윗첨자', 'subscript' => '아랫첨자', 'text_color' => '글자 색상', + 'highlight_color' => 'Highlight color', 'custom_color' => '사용자 지정 색상', 'remove_color' => '색상 제거', 'background_color' => '배경 색상', diff --git a/lang/ko/entities.php b/lang/ko/entities.php index 3de3c12b9..f3fea4913 100644 --- a/lang/ko/entities.php +++ b/lang/ko/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => '드로잉 추가', 'pages_md_show_preview' => '미리보기 표시', 'pages_md_sync_scroll' => '미리보기 스크롤 동기화', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => '저장되지 않은 드로잉 발견', 'pages_drawing_unsaved_confirm' => '이전에 실패한 드로잉 저장 시도에서 저장되지 않은 드로잉 데이터가 발견되었습니다. 이 저장되지 않은 드로잉을 복원하고 계속 편집하시겠습니까?', 'pages_not_in_chapter' => '챕터에 있는 문서가 아닙니다.', diff --git a/lang/ku/editor.php b/lang/ku/editor.php index 752c6f3f7..0d250e9a7 100644 --- a/lang/ku/editor.php +++ b/lang/ku/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Text color', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Custom color', 'remove_color' => 'Remove color', 'background_color' => 'Background color', diff --git a/lang/ku/entities.php b/lang/ku/entities.php index 561022ad6..ef625a3d2 100644 --- a/lang/ku/entities.php +++ b/lang/ku/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insert Drawing', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Page is not in a chapter', diff --git a/lang/lt/editor.php b/lang/lt/editor.php index 752c6f3f7..0d250e9a7 100644 --- a/lang/lt/editor.php +++ b/lang/lt/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Text color', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Custom color', 'remove_color' => 'Remove color', 'background_color' => 'Background color', diff --git a/lang/lt/entities.php b/lang/lt/entities.php index 6f73e7cb8..9677b6289 100644 --- a/lang/lt/entities.php +++ b/lang/lt/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Įterpti piešinį', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Puslapio nėra skyriuje', diff --git a/lang/lv/editor.php b/lang/lv/editor.php index ad794da9c..8b2907ddd 100644 --- a/lang/lv/editor.php +++ b/lang/lv/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Augšraksts', 'subscript' => 'Apakšraksts', 'text_color' => 'Teksta krāsa', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Pielāgot krāsu', 'remove_color' => 'Noņemt krāsu', 'background_color' => 'Fona krāsa', diff --git a/lang/lv/entities.php b/lang/lv/entities.php index 6958e8248..407900f79 100644 --- a/lang/lv/entities.php +++ b/lang/lv/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Ievietot zīmējumu', 'pages_md_show_preview' => 'Rādīt priekšskatu', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Atrasts nesaglabāts attēls', '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' => 'Lapa nav nodaļā', diff --git a/lang/nb/editor.php b/lang/nb/editor.php index ddb266626..77b01f17b 100644 --- a/lang/nb/editor.php +++ b/lang/nb/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Hevet skrift', 'subscript' => 'Senket skrift', 'text_color' => 'Tekstfarge', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Egenvalgt farge', 'remove_color' => 'Fjern farge', 'background_color' => 'Bakgrunnsfarge', diff --git a/lang/nb/entities.php b/lang/nb/entities.php index 8d0d5c6c4..4fcbd0534 100644 --- a/lang/nb/entities.php +++ b/lang/nb/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Sett inn tegning', 'pages_md_show_preview' => 'Forhåndsvisning', 'pages_md_sync_scroll' => 'Synkroniser forhåndsvisningsrulle', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Ulagret tegning funnet', 'pages_drawing_unsaved_confirm' => 'Ulagret tegningsdata ble funnet fra en tidligere mislykket lagring. Vil du gjenopprette og fortsette å redigere denne ulagrede tegningen?', 'pages_not_in_chapter' => 'Siden tilhører ingen kapittel', diff --git a/lang/ne/editor.php b/lang/ne/editor.php index 77c32ff30..f3b760ba8 100644 --- a/lang/ne/editor.php +++ b/lang/ne/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'सुपरस्क्रिप्ट', 'subscript' => 'सबस्क्रिप्ट', 'text_color' => 'पाठको रंग', + 'highlight_color' => 'Highlight color', 'custom_color' => 'अनुकूलित रंग', 'remove_color' => 'रंग हटाउनुहोस्', 'background_color' => 'पृष्ठभूमि रंग', diff --git a/lang/ne/entities.php b/lang/ne/entities.php index 8044515fa..da1a7e393 100644 --- a/lang/ne/entities.php +++ b/lang/ne/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'चित्र समावेश गर्नुहोस्', 'pages_md_show_preview' => 'पूर्वावलोकन देखाउनुहोस्', 'pages_md_sync_scroll' => 'पूर्वावलोकन स्क्रोल सिंक गर्नुहोस्', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'असुरक्षित चित्र भेटियो', 'pages_drawing_unsaved_confirm' => 'अघिल्लो असफल चित्र बचत प्रयासबाट असुरक्षित चित्र डेटा भेटिएको छ। के तपाईं यस असुरक्षित चित्रलाई पुनर्स्थापना गरेर सम्पादन गर्न चाहनुहुन्छ?', 'pages_not_in_chapter' => 'पाना कुनै अध्यायमा छैन', diff --git a/lang/nl/editor.php b/lang/nl/editor.php index e3d6df6bc..0963df71f 100644 --- a/lang/nl/editor.php +++ b/lang/nl/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Tekstkleur', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Aangepaste kleur', 'remove_color' => 'Verwijder kleur', 'background_color' => 'Tekstmarkeringskleur', diff --git a/lang/nl/entities.php b/lang/nl/entities.php index a3f03a16c..e5a8b85e1 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Tekening invoegen', 'pages_md_show_preview' => 'Toon voorbeeld', 'pages_md_sync_scroll' => 'Synchroniseer scrollen van voorbeeld', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Niet-opgeslagen Tekening Gevonden', 'pages_drawing_unsaved_confirm' => 'Er zijn niet-opgeslagen tekeninggegevens gevonden van een eerdere mislukte poging om de tekening op te slaan. Wil je deze niet-opgeslagen tekening herstellen en verder bewerken?', 'pages_not_in_chapter' => 'Pagina is niet in een hoofdstuk', diff --git a/lang/nn/editor.php b/lang/nn/editor.php index 74b31748a..6128223a9 100644 --- a/lang/nn/editor.php +++ b/lang/nn/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Heva skrift', 'subscript' => 'Senka skrift', 'text_color' => 'Tekstfarge', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Eigenvalgt farge', 'remove_color' => 'Fjern farge', 'background_color' => 'Bakgrunnsfarge', diff --git a/lang/nn/entities.php b/lang/nn/entities.php index b8b62ebe3..3449792cb 100644 --- a/lang/nn/entities.php +++ b/lang/nn/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Sett inn tegning', 'pages_md_show_preview' => 'Førhandsvisning', 'pages_md_sync_scroll' => 'Synkroniser førehandsvisingsrulle', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Ulagra teikning funne', 'pages_drawing_unsaved_confirm' => 'Ulagra teikninga vart funne frå ei tidligare mislykka lagring. Vil du gjenopprette og fortsette å redigere denne ulagra teikninga?', 'pages_not_in_chapter' => 'Sida tilhøyrer ingen kapittel', diff --git a/lang/pl/editor.php b/lang/pl/editor.php index 7ba9220cf..a93b5dd78 100644 --- a/lang/pl/editor.php +++ b/lang/pl/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Indeks górny', 'subscript' => 'Indeks dolny', 'text_color' => 'Kolor tekstu', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Kolor niestandardowy', 'remove_color' => 'Usuń kolor', 'background_color' => 'Kolor tła', diff --git a/lang/pl/entities.php b/lang/pl/entities.php index cbe75e79d..8b5907221 100644 --- a/lang/pl/entities.php +++ b/lang/pl/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Wstaw rysunek', 'pages_md_show_preview' => 'Pokaż podgląd', 'pages_md_sync_scroll' => 'Synchronizuj przewijanie podglądu', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Znaleziono niezapisany rysunek', 'pages_drawing_unsaved_confirm' => 'Znaleziono niezapisane dane rysowania z poprzedniej nieudanej próby zapisu. Czy chcesz przywrócić i kontynuować edycję tego niezapisanego rysunku?', 'pages_not_in_chapter' => 'Strona nie została umieszczona w rozdziale', diff --git a/lang/pt/editor.php b/lang/pt/editor.php index 222163102..b60f813b5 100644 --- a/lang/pt/editor.php +++ b/lang/pt/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superior à linha', 'subscript' => 'Inferior à linha', 'text_color' => 'Cor do texto', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Cor personalizada', 'remove_color' => 'Remover cor', 'background_color' => 'Cor de fundo', diff --git a/lang/pt/entities.php b/lang/pt/entities.php index d25982e95..b5c2fe545 100644 --- a/lang/pt/entities.php +++ b/lang/pt/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Inserir Desenho', 'pages_md_show_preview' => 'Mostrar pré-visualização', 'pages_md_sync_scroll' => 'Sincronizar pré-visualização', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Encontrado um rascunho não guardado', 'pages_drawing_unsaved_confirm' => 'Dados de um rascunho não guardado foi encontrado de um tentativa anteriormente falhada. Deseja restaurar e continuar a edição desse rascunho?', 'pages_not_in_chapter' => 'A página não está dentro de um capítulo', diff --git a/lang/pt_BR/editor.php b/lang/pt_BR/editor.php index ea4035a27..797c3ae88 100644 --- a/lang/pt_BR/editor.php +++ b/lang/pt_BR/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Sobrescrito', 'subscript' => 'Subscrito', 'text_color' => 'Cor do texto', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Cor personalizada', 'remove_color' => 'Remover cor', 'background_color' => 'Cor de fundo', diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index 3d3c7e78a..37eb64e1f 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Inserir Diagrama', 'pages_md_show_preview' => 'Mostrar pré-visualização', 'pages_md_sync_scroll' => 'Sincronizar pré-visualização', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Diagrama não-salvo encontrado', 'pages_drawing_unsaved_confirm' => 'Foram encontrados dados não-salvos de uma tentativa anterior de salvar o diagrama. Você gostaria de restaurá-los e continuar editando este diagrama?', 'pages_not_in_chapter' => 'Página não está dentro de um capítulo', diff --git a/lang/ro/editor.php b/lang/ro/editor.php index a61fdc6c8..951e027fe 100644 --- a/lang/ro/editor.php +++ b/lang/ro/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Scris sus', 'subscript' => 'Scris jos', 'text_color' => 'Culoare text', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Culoare personalizată', 'remove_color' => 'Elimină culoarea', 'background_color' => 'Culoare fundal', diff --git a/lang/ro/entities.php b/lang/ro/entities.php index 293ec396a..649b6fe86 100644 --- a/lang/ro/entities.php +++ b/lang/ro/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Inserează desen', 'pages_md_show_preview' => 'Arată previzualizarea', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Pagina nu este într-un capitol', diff --git a/lang/ru/activities.php b/lang/ru/activities.php index 3a13ecf46..d353b7a76 100644 --- a/lang/ru/activities.php +++ b/lang/ru/activities.php @@ -87,7 +87,7 @@ return [ // Imports 'import_create' => 'created import', 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', + 'import_run' => 'обновлен импорт', 'import_run_notification' => 'Content successfully imported', 'import_delete' => 'deleted import', 'import_delete_notification' => 'Import successfully deleted', diff --git a/lang/ru/editor.php b/lang/ru/editor.php index 264ab7ec3..bd0c45f31 100644 --- a/lang/ru/editor.php +++ b/lang/ru/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Надстрочный', 'subscript' => 'Подстрочный', 'text_color' => 'Цвет текста', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Пользовательский цвет', 'remove_color' => 'Удалить цвет', 'background_color' => 'Цвет фона', diff --git a/lang/ru/entities.php b/lang/ru/entities.php index 2d5e0f4e7..0d3c98e56 100644 --- a/lang/ru/entities.php +++ b/lang/ru/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Вставить рисунок', 'pages_md_show_preview' => 'Предпросмотр', 'pages_md_sync_scroll' => 'Синхронизировать прокрутку', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Найден несохраненный чертеж', 'pages_drawing_unsaved_confirm' => 'Несохраненные данные были найдены из предыдущей неудачной попытки сохранения рисунка. Вы хотите восстановить и продолжить редактирование несохраненного рисунка?', 'pages_not_in_chapter' => 'Страница не находится в главе', diff --git a/lang/sk/editor.php b/lang/sk/editor.php index 3c098e3b3..7d1781ef4 100644 --- a/lang/sk/editor.php +++ b/lang/sk/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Horný index', 'subscript' => 'Dolný index', 'text_color' => 'Farba textu', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Vlastná farba', 'remove_color' => 'Odstrániť farbu', 'background_color' => 'Farba pozadia', diff --git a/lang/sk/entities.php b/lang/sk/entities.php index 996682e2a..20f2b294e 100644 --- a/lang/sk/entities.php +++ b/lang/sk/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Vložiť kresbu', 'pages_md_show_preview' => 'Zobraziť náhľad', 'pages_md_sync_scroll' => 'Posúvanie ukážky synchronizácie', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Stránka nie je v kapitole', diff --git a/lang/sl/editor.php b/lang/sl/editor.php index c050d6b37..3afcc2312 100644 --- a/lang/sl/editor.php +++ b/lang/sl/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Nadpisano', 'subscript' => 'Podpisano', 'text_color' => 'Barva besedila', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Barva po meri', 'remove_color' => 'Odstrani barvo', 'background_color' => 'Barva ozadja', diff --git a/lang/sl/entities.php b/lang/sl/entities.php index 0e4c0e1d6..cd1a0b488 100644 --- a/lang/sl/entities.php +++ b/lang/sl/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Vstavi risbo', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Stran ni v poglavju', diff --git a/lang/sq/activities.php b/lang/sq/activities.php index fbe7e3179..9296d1759 100644 --- a/lang/sq/activities.php +++ b/lang/sq/activities.php @@ -85,12 +85,12 @@ return [ 'webhook_delete_notification' => 'Uebhook-u u fshi me sukses', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => 'importi i krijuar', + 'import_create_notification' => 'Importi u ngarkua me sukses', + 'import_run' => 'Importi i përditësuar', + 'import_run_notification' => 'Përmbajtja u importua me sukses', + 'import_delete' => 'Importi i fshirë', + 'import_delete_notification' => 'Importi u fshi me sukses', // Users 'user_create' => 'krijoi përdorues', @@ -101,11 +101,11 @@ return [ 'user_delete_notification' => 'Përdoruesi u fshi me sukses', // API Tokens - 'api_token_create' => 'created API token', + 'api_token_create' => 'Krijoi token API', 'api_token_create_notification' => 'Token API u krijua me sukses', - 'api_token_update' => 'updated API token', + 'api_token_update' => 'Token i përditësuar i API-t', 'api_token_update_notification' => 'Token API u përditësua me sukses', - 'api_token_delete' => 'deleted API token', + 'api_token_delete' => 'Fshiu tokenin API', 'api_token_delete_notification' => 'Token API u fshi me sukses', // Roles @@ -128,12 +128,12 @@ return [ 'comment_delete' => 'fshiu koment', // Sort Rules - 'sort_rule_create' => 'created sort rule', - 'sort_rule_create_notification' => 'Sort rule successfully created', - 'sort_rule_update' => 'updated sort rule', - 'sort_rule_update_notification' => 'Sort rule successfully updated', - 'sort_rule_delete' => 'deleted sort rule', - 'sort_rule_delete_notification' => 'Sort rule successfully deleted', + 'sort_rule_create' => 'Rregull i krijuar renditjeje', + 'sort_rule_create_notification' => 'Rregulli i renditjes u krijua me sukses', + 'sort_rule_update' => 'rregulli i renditjes i përditësuar', + 'sort_rule_update_notification' => 'Rregulli i renditjes u përditësua me sukses', + 'sort_rule_delete' => 'rregulli i renditjes është fshirë', + 'sort_rule_delete_notification' => 'Rregulli i renditjes u fshi me sukses', // Other 'permissions_update' => 'përditësoi lejet', diff --git a/lang/sq/auth.php b/lang/sq/auth.php index e3259a961..6fdc4ee4a 100644 --- a/lang/sq/auth.php +++ b/lang/sq/auth.php @@ -50,34 +50,34 @@ return [ 'reset_password_sent' => 'Një link për rikthimin e fjalëkalimit do ju dërgohet në :email nëse adresa e email-it ndodhet në sistem.', 'reset_password_success' => 'Fjalëkalimi juaj u rikthye me sukses.', 'email_reset_subject' => 'Rikthe fjalëkalimin për :appName', - '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_reset_text' => 'Ju po e merrni këtë email sepse ne morëm një kërkesë për rivendosjen e fjalëkalimit për llogarinë tuaj.', + 'email_reset_not_requested' => 'Nëse nuk keni kërkuar rivendosjen e fjalëkalimit, nuk kërkohet asnjë veprim i mëtejshëm.', // 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_confirm_subject' => 'Konfirmo email-in tënd në :appName', + 'email_confirm_greeting' => 'Faleminderit që u bashkuat me :appName!', + 'email_confirm_text' => 'Ju lutemi konfirmoni adresën tuaj të email-it duke klikuar butonin më poshtë:', + 'email_confirm_action' => 'Konfirmo email-in', + 'email_confirm_send_error' => 'Kërkohet konfirmimi i email-it, por sistemi nuk mundi ta dërgonte email-in. Kontaktoni administratorin për t\'u siguruar që email-i është konfiguruar saktë.', + 'email_confirm_success' => 'Email-i juaj është konfirmuar! Tani duhet të jeni në gjendje të hyni në sistem duke përdorur këtë adresë email-i.', + 'email_confirm_resent' => 'Emaili i konfirmimit u ridërgua, ju lutem kontrolloni kutinë tuaj postare.', + 'email_confirm_thanks' => 'Faleminderit që konfirmuat!', + 'email_confirm_thanks_desc' => 'Ju lutemi prisni një moment ndërsa konfirmimi juaj përpunohet. Nëse nuk ridrejtoheni pas 3 sekondash, shtypni linkun "Vazhdo" më poshtë për të vazhduar.', - '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', + 'email_not_confirmed' => 'Adresa e email-it nuk është konfirmuar', + 'email_not_confirmed_text' => 'Adresa juaj e email-it nuk është konfirmuar ende.', + 'email_not_confirmed_click_link' => 'Ju lutemi klikoni linkun në emailin që ju është dërguar menjëherë pasi u regjistruat.', + 'email_not_confirmed_resend' => 'Nëse nuk mund ta gjeni email-in, mund ta ridërgoni email-in e konfirmimit duke plotësuar formularin më poshtë.', + 'email_not_confirmed_resend_button' => 'Ridërgo emailin e konfirmimit', // 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_email_subject' => 'Je ftuar të bashkohesh me :appName!', + 'user_invite_email_greeting' => 'Një llogari është krijuar për ty në :appName.', + 'user_invite_email_text' => 'Klikoni butonin më poshtë për të vendosur një fjalëkalim llogarie dhe për të fituar akses:', + 'user_invite_email_action' => 'Vendos fjalëkalimin e llogarisë', + 'user_invite_page_welcome' => 'Mirë se vini në :appName!', + 'user_invite_page_text' => 'Për të finalizuar llogarinë tuaj dhe për të fituar akses, duhet të vendosni një fjalëkalim i cili do të përdoret për t\'u kyçur në :appName në vizitat e ardhshme.', + 'user_invite_page_confirm_button' => 'Konfirmo fjalëkalimin', 'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!', // Multi-factor Authentication diff --git a/lang/sq/editor.php b/lang/sq/editor.php index 752c6f3f7..0d250e9a7 100644 --- a/lang/sq/editor.php +++ b/lang/sq/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Text color', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Custom color', 'remove_color' => 'Remove color', 'background_color' => 'Background color', diff --git a/lang/sq/entities.php b/lang/sq/entities.php index 561022ad6..ef625a3d2 100644 --- a/lang/sq/entities.php +++ b/lang/sq/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insert Drawing', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Page is not in a chapter', diff --git a/lang/sr/editor.php b/lang/sr/editor.php index 5db25faf3..e5052595c 100644 --- a/lang/sr/editor.php +++ b/lang/sr/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Надскрипт', 'subscript' => 'Субкрипт', 'text_color' => 'Боја текста', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Боја текста', 'remove_color' => 'Уклоните боју', 'background_color' => 'Боја позадине', diff --git a/lang/sr/entities.php b/lang/sr/entities.php index facf933ad..869219165 100644 --- a/lang/sr/entities.php +++ b/lang/sr/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insert Drawing', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Page is not in a chapter', diff --git a/lang/sv/editor.php b/lang/sv/editor.php index a1e175ade..c2485817d 100644 --- a/lang/sv/editor.php +++ b/lang/sv/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Upphöjd', 'subscript' => 'Nedsänkt', 'text_color' => 'Textfärg', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Anpassad färg', 'remove_color' => 'Ta bort färg', 'background_color' => 'Bakgrundsfärg', diff --git a/lang/sv/entities.php b/lang/sv/entities.php index 06dbecf7e..adb497c2a 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Infoga teckning', 'pages_md_show_preview' => 'Visa förhandsgranskning', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Osparad ritning hittades', 'pages_drawing_unsaved_confirm' => 'Osparade ritningsdata hittades från ett tidigare misslyckat sparförsök. Vill du återställa och fortsätta redigera den osparade ritningen?', 'pages_not_in_chapter' => 'Sidan ligger inte i något kapitel', diff --git a/lang/tk/editor.php b/lang/tk/editor.php index 752c6f3f7..0d250e9a7 100644 --- a/lang/tk/editor.php +++ b/lang/tk/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Text color', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Custom color', 'remove_color' => 'Remove color', 'background_color' => 'Background color', diff --git a/lang/tk/entities.php b/lang/tk/entities.php index 561022ad6..ef625a3d2 100644 --- a/lang/tk/entities.php +++ b/lang/tk/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Insert Drawing', 'pages_md_show_preview' => 'Show preview', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Page is not in a chapter', diff --git a/lang/tr/editor.php b/lang/tr/editor.php index 6bb1d5db8..c020c82fd 100644 --- a/lang/tr/editor.php +++ b/lang/tr/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Üst simge', 'subscript' => 'Alt simge', 'text_color' => 'Metin rengi', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Özel renk', 'remove_color' => 'Rengi kaldır', 'background_color' => 'Arka plan rengi', diff --git a/lang/tr/entities.php b/lang/tr/entities.php index 02eda9823..a9c6eebd8 100644 --- a/lang/tr/entities.php +++ b/lang/tr/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Çizim Ekle', 'pages_md_show_preview' => 'Önizlemeyi göster', 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Unsaved Drawing Found', 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', 'pages_not_in_chapter' => 'Bu sayfa, bir bölüme ait değil', diff --git a/lang/uk/editor.php b/lang/uk/editor.php index f887b3203..6dbeb509b 100644 --- a/lang/uk/editor.php +++ b/lang/uk/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Верхній індекс', 'subscript' => 'Нижній індекс', 'text_color' => 'Колір тексту', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Власний колір', 'remove_color' => 'Видалити колір', 'background_color' => 'Колір фону', diff --git a/lang/uk/entities.php b/lang/uk/entities.php index ea74e8823..a72fac1a4 100644 --- a/lang/uk/entities.php +++ b/lang/uk/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Вставити малюнок', 'pages_md_show_preview' => 'Показати попередній перегляд', 'pages_md_sync_scroll' => 'Синхронізація прокручування попереднього перегляду', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Знайдено незбережену чернетку', 'pages_drawing_unsaved_confirm' => 'Незбережені чернетки були знайдені з попередньої спроби зберегти звіт. Хочете відновити і продовжити редагування цієї чернетки?', 'pages_not_in_chapter' => 'Сторінка не знаходиться в розділі', diff --git a/lang/uz/editor.php b/lang/uz/editor.php index 905855b4c..a7f91d769 100644 --- a/lang/uz/editor.php +++ b/lang/uz/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Yuqori yozuv', 'subscript' => 'Subscript', 'text_color' => 'Matn rangi', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Maxsus rang', 'remove_color' => 'Rangni olib tashlash', 'background_color' => 'Fon rangi', diff --git a/lang/uz/entities.php b/lang/uz/entities.php index 7edd8449f..7c88e346c 100644 --- a/lang/uz/entities.php +++ b/lang/uz/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Chizma kiritish', 'pages_md_show_preview' => 'Ko‘rish', 'pages_md_sync_scroll' => 'Sinxronizatsiyani oldindan ko\'rish aylantirish', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => 'Saqlanmagan chizma topildi', 'pages_drawing_unsaved_confirm' => 'Saqlanmagan chizma maʼlumotlari avvalgi muvaffaqiyatsiz chizmani saqlash urinishidan topildi. Ushbu saqlanmagan chizmani qayta tiklash va tahrirlashni davom ettirmoqchimisiz?', 'pages_not_in_chapter' => 'Sahifa bir bobda emas', diff --git a/lang/vi/editor.php b/lang/vi/editor.php index ff089f67a..d76a70815 100644 --- a/lang/vi/editor.php +++ b/lang/vi/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Chỉ số trên', 'subscript' => 'Chỉ số dưới', 'text_color' => 'Màu chữ', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Màu tùy chỉnh', 'remove_color' => 'Xóa màu', 'background_color' => 'Màu nền', diff --git a/lang/vi/entities.php b/lang/vi/entities.php index c08fd01dd..6d740f1bc 100644 --- a/lang/vi/entities.php +++ b/lang/vi/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => 'Chèn bản vẽ', 'pages_md_show_preview' => 'Hiển thị bản xem trước', 'pages_md_sync_scroll' => 'Đồng bộ hóa cuộn xem trước', + 'pages_md_plain_editor' => 'Trình soạn thảo văn bản thuần túy', 'pages_drawing_unsaved' => 'Tìm thấy bản vẽ chưa lưu', 'pages_drawing_unsaved_confirm' => 'Dữ liệu bản vẽ chưa lưu được tìm thấy từ lần lưu bản vẽ không thành công trước đó. Bạn có muốn khôi phục và tiếp tục chỉnh sửa bản vẽ chưa lưu này không?', 'pages_not_in_chapter' => 'Trang không nằm trong một chương', diff --git a/lang/zh_CN/common.php b/lang/zh_CN/common.php index 59a70f78e..dde4bdf7d 100644 --- a/lang/zh_CN/common.php +++ b/lang/zh_CN/common.php @@ -30,8 +30,8 @@ return [ 'create' => '创建', 'update' => '更新', 'edit' => '编辑', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => '存档', + 'unarchive' => '取消存档', 'sort' => '排序', 'move' => '移动', 'copy' => '复制', diff --git a/lang/zh_CN/editor.php b/lang/zh_CN/editor.php index 8eb335ea2..6dbca56e3 100644 --- a/lang/zh_CN/editor.php +++ b/lang/zh_CN/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => '上标', 'subscript' => '下标', 'text_color' => '文本颜色', + 'highlight_color' => 'Highlight color', 'custom_color' => '自定义颜色', 'remove_color' => '移除颜色', 'background_color' => '背景色', diff --git a/lang/zh_CN/entities.php b/lang/zh_CN/entities.php index 5b67ee0c4..d60e01e0b 100644 --- a/lang/zh_CN/entities.php +++ b/lang/zh_CN/entities.php @@ -168,7 +168,7 @@ return [ 'books_sort' => '排序图书内容', 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.', 'books_sort_auto_sort' => '自动排序选项', - 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', + 'books_sort_auto_sort_active' => '自动排序已激活:::sortName', 'books_sort_named' => '排序图书「:bookName」', 'books_sort_name' => '按名称排序', 'books_sort_created' => '创建时间排序', @@ -248,7 +248,7 @@ return [ 'pages_edit_switch_to_markdown_stable' => '(保留内容)', 'pages_edit_switch_to_wysiwyg' => '切换到所见即所得编辑器', 'pages_edit_switch_to_new_wysiwyg' => '切换到新的所见即所得', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(正在Beta测试中)', 'pages_edit_set_changelog' => '更新说明', 'pages_edit_enter_changelog_desc' => '输入对您所做更改的简要说明', 'pages_edit_enter_changelog' => '输入更新说明', @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => '插入图表', 'pages_md_show_preview' => '显示预览', 'pages_md_sync_scroll' => '同步预览滚动', + 'pages_md_plain_editor' => '纯文本编辑器', 'pages_drawing_unsaved' => '找到未保存的绘图', 'pages_drawing_unsaved_confirm' => '从之前保存失败的绘图中发现了可恢复的数据。您想恢复并继续编辑这个未保存的绘图吗?', 'pages_not_in_chapter' => '本页面不在某章节中', @@ -395,8 +396,8 @@ return [ 'comment_none' => '没有要显示的评论', 'comment_placeholder' => '在这里评论', 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', - 'comment_archived_threads' => 'Archived Threads', + 'comment_archived_count' => ':count 条评论已存档', + 'comment_archived_threads' => '已存档的贴子', 'comment_save' => '保存评论', 'comment_new' => '新评论', 'comment_created' => '评论于 :createDiff', @@ -405,14 +406,14 @@ return [ 'comment_deleted_success' => '评论已删除', 'comment_created_success' => '评论已添加', 'comment_updated_success' => '评论已更新', - 'comment_archive_success' => 'Comment archived', - 'comment_unarchive_success' => 'Comment un-archived', + 'comment_archive_success' => '评论已存档', + 'comment_unarchive_success' => '评论已取消存档', 'comment_view' => '查看评论', - 'comment_jump_to_thread' => 'Jump to thread', + 'comment_jump_to_thread' => '跳转到贴子', 'comment_delete_confirm' => '您确定要删除这条评论?', 'comment_in_reply_to' => '回复 :commentId', - 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', + 'comment_reference' => '参考', + 'comment_reference_outdated' => '(已过时)', 'comment_editor_explain' => '这里是此页面上留下的评论。查看已保存的页面时可以添加和管理评论。', // Revision diff --git a/lang/zh_TW/common.php b/lang/zh_TW/common.php index 30388c3f4..0c64ac835 100644 --- a/lang/zh_TW/common.php +++ b/lang/zh_TW/common.php @@ -30,7 +30,7 @@ return [ 'create' => '建立', 'update' => '更新', 'edit' => '編輯', - 'archive' => 'Archive', + 'archive' => '歸檔', 'unarchive' => 'Un-Archive', 'sort' => '排序', 'move' => '移動', diff --git a/lang/zh_TW/editor.php b/lang/zh_TW/editor.php index 9319e64a4..93c8d571d 100644 --- a/lang/zh_TW/editor.php +++ b/lang/zh_TW/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => '上標', 'subscript' => '下標', 'text_color' => '文本顏色', + 'highlight_color' => 'Highlight color', 'custom_color' => '自訂顏色', 'remove_color' => '移除颜色', 'background_color' => '背景顏色', diff --git a/lang/zh_TW/entities.php b/lang/zh_TW/entities.php index fccf0ef59..21ff15124 100644 --- a/lang/zh_TW/entities.php +++ b/lang/zh_TW/entities.php @@ -268,6 +268,7 @@ return [ 'pages_md_insert_drawing' => '插入繪圖', 'pages_md_show_preview' => '顯示預覽', 'pages_md_sync_scroll' => '預覽頁面同步捲動', + 'pages_md_plain_editor' => 'Plaintext editor', 'pages_drawing_unsaved' => '偵測到未儲存的繪圖', 'pages_drawing_unsaved_confirm' => '從之前保存失敗的繪圖中發現了可恢復的數據。您想恢復並繼續編輯這個未保存的繪圖嗎?', 'pages_not_in_chapter' => '頁面不在章節中', diff --git a/package-lock.json b/package-lock.json index 926a6d9e3..079e39770 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "devDependencies": { "@eslint/js": "^9.21.0", "@lezer/generator": "^1.7.2", + "@types/markdown-it": "^14.1.2", "@types/sortablejs": "^1.15.8", "chokidar-cli": "^3.0", "esbuild": "^0.25.0", @@ -76,9 +77,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz", - "integrity": "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", "dev": true, "license": "MIT", "engines": { @@ -86,22 +87,22 @@ } }, "node_modules/@babel/core": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.3.tgz", - "integrity": "sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", + "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.3", - "@babel/parser": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.3", - "@babel/types": "^7.27.3", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -117,16 +118,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", - "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.3", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -150,6 +151,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", @@ -222,27 +233,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.3.tgz", - "integrity": "sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3" + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.3.tgz", - "integrity": "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -506,38 +517,28 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.3.tgz", - "integrity": "sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.3", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/types": "^7.28.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", - "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", + "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -625,9 +626,9 @@ } }, "node_modules/@codemirror/lang-json": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz", - "integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -635,9 +636,9 @@ } }, "node_modules/@codemirror/lang-markdown": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.2.tgz", - "integrity": "sha512-c/5MYinGbFxYl4itE9q/rgN/sMTjOr8XL5OWnC+EaRMLfCbVUmmubTJfdgpfcSS2SCaT7b+Q+xi3l6CgoE+BsA==", + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.3.tgz", + "integrity": "sha512-1fn1hQAPWlSSMCvnF810AkhWpNLkJpl66CRfIy3vVl20Sl4NwChkorCHqpMtNbXr1EuMJsrDnhEpjZxKZ2UX3A==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.7.1", @@ -650,9 +651,9 @@ } }, "node_modules/@codemirror/lang-php": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.1.tgz", - "integrity": "sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", "license": "MIT", "dependencies": { "@codemirror/lang-html": "^6.0.0", @@ -677,9 +678,9 @@ } }, "node_modules/@codemirror/language": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.0.tgz", - "integrity": "sha512-A7+f++LodNNc1wGgoRDTt78cOwWm9KVezApgjOMp1W4hM0898nsqBXwF+sbePE7ZRcjN7Sa1Z5m2oN27XkmEjQ==", + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz", + "integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -731,9 +732,9 @@ } }, "node_modules/@codemirror/theme-one-dark": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", - "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -743,12 +744,13 @@ } }, "node_modules/@codemirror/view": { - "version": "6.36.8", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.8.tgz", - "integrity": "sha512-yoRo4f+FdnD01fFt4XpfpMCcCAo9QvZOtbrXExn4SqzH32YC6LgzqxfLZw/r6Ge65xyY03mK/UfUqrVw1gFiFg==", + "version": "6.38.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", + "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } @@ -778,9 +780,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", "cpu": [ "ppc64" ], @@ -795,9 +797,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", "cpu": [ "arm" ], @@ -812,9 +814,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", "cpu": [ "arm64" ], @@ -829,9 +831,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", "cpu": [ "x64" ], @@ -846,9 +848,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", "cpu": [ "arm64" ], @@ -863,9 +865,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", "cpu": [ "x64" ], @@ -880,9 +882,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", "cpu": [ "arm64" ], @@ -897,9 +899,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", "cpu": [ "x64" ], @@ -914,9 +916,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", "cpu": [ "arm" ], @@ -931,9 +933,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", "cpu": [ "arm64" ], @@ -948,9 +950,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", "cpu": [ "ia32" ], @@ -965,9 +967,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", "cpu": [ "loong64" ], @@ -982,9 +984,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", "cpu": [ "mips64el" ], @@ -999,9 +1001,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", "cpu": [ "ppc64" ], @@ -1016,9 +1018,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", "cpu": [ "riscv64" ], @@ -1033,9 +1035,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", "cpu": [ "s390x" ], @@ -1050,9 +1052,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", "cpu": [ "x64" ], @@ -1067,9 +1069,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", "cpu": [ "arm64" ], @@ -1084,9 +1086,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", "cpu": [ "x64" ], @@ -1101,9 +1103,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", "cpu": [ "arm64" ], @@ -1118,9 +1120,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", "cpu": [ "x64" ], @@ -1134,10 +1136,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", "cpu": [ "x64" ], @@ -1152,9 +1171,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", "cpu": [ "arm64" ], @@ -1169,9 +1188,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", "cpu": [ "ia32" ], @@ -1186,9 +1205,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", "cpu": [ "x64" ], @@ -1245,9 +1264,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1260,9 +1279,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1270,9 +1289,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1307,9 +1326,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", - "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", + "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", "dev": true, "license": "MIT", "engines": { @@ -1330,13 +1349,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", - "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { @@ -1816,18 +1835,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1840,27 +1855,17 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1875,9 +1880,9 @@ "license": "MIT" }, "node_modules/@lezer/css": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.2.1.tgz", - "integrity": "sha512-2F5tOqzKEKbCUNraIXc0f6HKeyKlmMWJnBB0i4XW6dJgssrZO/YlZ2pY5xgyqDleqqhiNJ3dQhbrV2aClZQMvg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", + "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -1886,9 +1891,9 @@ } }, "node_modules/@lezer/generator": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.3.tgz", - "integrity": "sha512-vAI2O1tPF8QMMgp+bdUeeJCneJNkOZvqsrtyb4ohnFVFdboSqPwBEacnt0HH4E+5h+qsIwTHUSAhffU4hzKl1A==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.8.0.tgz", + "integrity": "sha512-/SF4EDWowPqV1jOgoGSGTIFsE7Ezdr7ZYxyihl5eMKVO5tlnpIhFcDavgm1hHY5GEonoOAEnJ0CU0x+tvuAuUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1961,9 +1966,9 @@ } }, "node_modules/@lezer/php": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.2.tgz", - "integrity": "sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.4.tgz", + "integrity": "sha512-D2dJ0t8Z28/G1guztRczMFvPDUqzeMLSQbdWQmaiHV7urc8NlEOnjYk9UrZ531OcLiRxD4Ihcbv7AsDpNKDRaQ==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -2432,9 +2437,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -2508,13 +2513,38 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" } }, "node_modules/@types/sortablejs": { @@ -2561,9 +2591,9 @@ "license": "BSD-3-Clause" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -2723,18 +2753,20 @@ } }, "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3002,9 +3034,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -3025,9 +3057,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "dev": true, "funding": [ { @@ -3045,8 +3077,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -3158,9 +3190,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "dev": true, "funding": [ { @@ -3317,9 +3349,9 @@ } }, "node_modules/codemirror": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", - "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -3558,9 +3590,9 @@ } }, "node_modules/decimal.js": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", - "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" }, @@ -3744,9 +3776,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.158", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.158.tgz", - "integrity": "sha512-9vcp2xHhkvraY6AHw2WMi+GDSLPX42qe2xjYaVoZqFRJiOcilVQFq9mZmpuHEQpzlgGDelKlV7ZiGcmMsc8WxQ==", + "version": "1.5.190", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz", + "integrity": "sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==", "dev": true, "license": "ISC" }, @@ -3793,9 +3825,9 @@ } }, "node_modules/es-abstract": { - "version": "1.23.10", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.10.tgz", - "integrity": "sha512-MtUbM072wlJNyeYAe0mhzrD+M6DIJa96CZAOBBrhDbgKnB4MApIKefcyAB1eOdYn8cUNZgvwBvEzdoAYsxgEIw==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "dev": true, "license": "MIT", "dependencies": { @@ -3826,7 +3858,9 @@ "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", + "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", @@ -3841,6 +3875,7 @@ "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", @@ -3939,9 +3974,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3952,31 +3987,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" } }, "node_modules/escalade": { @@ -4025,19 +4061,19 @@ } }, "node_modules/eslint": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", - "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", + "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.27.0", + "@eslint/js": "9.31.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -4049,9 +4085,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4108,9 +4144,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, "license": "MIT", "dependencies": { @@ -4136,30 +4172,30 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", + "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", - "is-core-module": "^2.15.1", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", - "object.values": "^1.2.0", + "object.values": "^1.2.1", "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", + "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "engines": { @@ -4180,9 +4216,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -4197,9 +4233,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4223,15 +4259,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4404,9 +4440,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4493,15 +4529,16 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -4944,9 +4981,9 @@ } }, "node_modules/immutable": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", - "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", "dev": true, "license": "MIT" }, @@ -5277,6 +5314,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7323,9 +7373,9 @@ } }, "node_modules/parse5/node_modules/entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -7850,9 +7900,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.0.tgz", - "integrity": "sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ==", + "version": "1.89.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", + "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", "dev": true, "license": "MIT", "dependencies": { @@ -8003,9 +8053,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "dev": true, "license": "MIT", "engines": { @@ -8224,6 +8274,20 @@ "node": ">=8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -8502,16 +8566,15 @@ } }, "node_modules/ts-jest": { - "version": "29.3.4", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.4.tgz", - "integrity": "sha512-Iqbrm8IXOmV+ggWHOTEbjwyCf2xZlUMv5npExksXohL+tk8va4Fjhb+X2+Rt9NBmgO7bJ8WpnMLOwih/DnMlFA==", + "version": "29.4.0", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", + "integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "ejs": "^3.1.10", "fast-json-stable-stringify": "^2.1.0", - "jest-util": "^29.0.0", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", @@ -8527,10 +8590,11 @@ }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "peerDependenciesMeta": { @@ -8548,6 +8612,9 @@ }, "esbuild": { "optional": true + }, + "jest-util": { + "optional": true } } }, @@ -8811,9 +8878,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "license": "MIT" }, "node_modules/universalify": { @@ -9199,9 +9266,9 @@ } }, "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 5d94537d1..151338d8c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "devDependencies": { "@eslint/js": "^9.21.0", "@lezer/generator": "^1.7.2", + "@types/markdown-it": "^14.1.2", "@types/sortablejs": "^1.15.8", "chokidar-cli": "^3.0", "esbuild": "^0.25.0", diff --git a/readme.md b/readme.md index e1c890e0b..b5470af57 100644 --- a/readme.md +++ b/readme.md @@ -87,9 +87,12 @@ Big thanks to these companies for supporting the project. - + phamos + + SiteSpeakAI + @@ -103,7 +106,7 @@ Details about BookStack's versioning scheme and the general release process [can ## 🌎 Translations -Translations for text within BookStack is managed through the [BookStack project on Crowdin](https://crowdin.com/project/bookstack). Some strings have colon-prefixed variables such as `:userName`. Leave these values as they are as they will be replaced at run-time. +Translations for text within BookStack are managed through the [BookStack project on Crowdin](https://crowdin.com/project/bookstack). Some strings have colon-prefixed variables such as `:userName`. Leave these values as they are as they will be replaced at run-time. Please use [Crowdin](https://crowdin.com/project/bookstack) to contribute translations instead of opening a pull request. The translations within the working codebase can be out-of-date, and merging via code can cause conflicts & sync issues. If for some reason you can't use Crowdin feel free to open an issue to discuss alternative options. diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index 5dd5dd93b..d2b044ee1 100644 --- a/resources/js/components/dropdown.js +++ b/resources/js/components/dropdown.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom.ts'; +import {findClosestScrollContainer, onSelect} from '../services/dom.ts'; import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts'; import {Component} from './component'; @@ -33,7 +33,8 @@ export class Dropdown extends Component { const menuOriginalRect = this.menu.getBoundingClientRect(); let heightOffset = 0; const toggleHeight = this.toggle.getBoundingClientRect().height; - const dropUpwards = menuOriginalRect.bottom > window.innerHeight; + const containerBounds = findClosestScrollContainer(this.menu).getBoundingClientRect(); + const dropUpwards = menuOriginalRect.bottom > containerBounds.bottom; const containerRect = this.container.getBoundingClientRect(); // If enabled, Move to body to prevent being trapped within scrollable sections diff --git a/resources/js/components/entity-selector-popup.js b/resources/js/components/entity-selector-popup.ts similarity index 58% rename from resources/js/components/entity-selector-popup.js rename to resources/js/components/entity-selector-popup.ts index 29c06e909..468f074b5 100644 --- a/resources/js/components/entity-selector-popup.js +++ b/resources/js/components/entity-selector-popup.ts @@ -1,15 +1,23 @@ import {Component} from './component'; +import {EntitySelector, EntitySelectorEntity, EntitySelectorSearchOptions} from "./entity-selector"; +import {Popup} from "./popup"; + +export type EntitySelectorPopupCallback = (entity: EntitySelectorEntity) => void; export class EntitySelectorPopup extends Component { + protected container!: HTMLElement; + protected selectButton!: HTMLElement; + protected selectorEl!: HTMLElement; + + protected callback: EntitySelectorPopupCallback|null = null; + protected selection: EntitySelectorEntity|null = null; + setup() { this.container = this.$el; this.selectButton = this.$refs.select; this.selectorEl = this.$refs.selector; - this.callback = null; - this.selection = null; - this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this)); window.$events.listen('entity-select-change', this.onSelectionChange.bind(this)); window.$events.listen('entity-select-confirm', this.handleConfirmedSelection.bind(this)); @@ -17,10 +25,8 @@ export class EntitySelectorPopup extends Component { /** * Show the selector popup. - * @param {Function} callback - * @param {EntitySelectorSearchOptions} searchOptions */ - show(callback, searchOptions = {}) { + show(callback: EntitySelectorPopupCallback, searchOptions: Partial = {}) { this.callback = callback; this.getSelector().configureSearchOptions(searchOptions); this.getPopup().show(); @@ -32,34 +38,28 @@ export class EntitySelectorPopup extends Component { this.getPopup().hide(); } - /** - * @returns {Popup} - */ - getPopup() { - return window.$components.firstOnElement(this.container, 'popup'); + getPopup(): Popup { + return window.$components.firstOnElement(this.container, 'popup') as Popup; } - /** - * @returns {EntitySelector} - */ - getSelector() { - return window.$components.firstOnElement(this.selectorEl, 'entity-selector'); + getSelector(): EntitySelector { + return window.$components.firstOnElement(this.selectorEl, 'entity-selector') as EntitySelector; } onSelectButtonClick() { this.handleConfirmedSelection(this.selection); } - onSelectionChange(entity) { - this.selection = entity; - if (entity === null) { + onSelectionChange(entity: EntitySelectorEntity|{}) { + this.selection = (entity.hasOwnProperty('id') ? entity : null) as EntitySelectorEntity|null; + if (!this.selection) { this.selectButton.setAttribute('disabled', 'true'); } else { this.selectButton.removeAttribute('disabled'); } } - handleConfirmedSelection(entity) { + handleConfirmedSelection(entity: EntitySelectorEntity|null): void { this.hide(); this.getSelector().reset(); if (this.callback && entity) this.callback(entity); diff --git a/resources/js/components/entity-selector.js b/resources/js/components/entity-selector.ts similarity index 74% rename from resources/js/components/entity-selector.js rename to resources/js/components/entity-selector.ts index 7491119a1..0ae9710f7 100644 --- a/resources/js/components/entity-selector.js +++ b/resources/js/components/entity-selector.ts @@ -1,24 +1,36 @@ -import {onChildEvent} from '../services/dom.ts'; +import {onChildEvent} from '../services/dom'; import {Component} from './component'; -/** - * @typedef EntitySelectorSearchOptions - * @property entityTypes string - * @property entityPermission string - * @property searchEndpoint string - * @property initialValue string - */ +export interface EntitySelectorSearchOptions { + entityTypes: string; + entityPermission: string; + searchEndpoint: string; + initialValue: string; +} + +export type EntitySelectorEntity = { + id: number, + name: string, + link: string, +}; -/** - * Entity Selector - */ export class EntitySelector extends Component { + protected elem!: HTMLElement; + protected input!: HTMLInputElement; + protected searchInput!: HTMLInputElement; + protected loading!: HTMLElement; + protected resultsContainer!: HTMLElement; + + protected searchOptions!: EntitySelectorSearchOptions; + + protected search = ''; + protected lastClick = 0; setup() { this.elem = this.$el; - this.input = this.$refs.input; - this.searchInput = this.$refs.search; + this.input = this.$refs.input as HTMLInputElement; + this.searchInput = this.$refs.search as HTMLInputElement; this.loading = this.$refs.loading; this.resultsContainer = this.$refs.results; @@ -29,9 +41,6 @@ export class EntitySelector extends Component { initialValue: this.searchInput.value || '', }; - this.search = ''; - this.lastClick = 0; - this.setupListeners(); this.showLoading(); @@ -40,16 +49,13 @@ export class EntitySelector extends Component { } } - /** - * @param {EntitySelectorSearchOptions} options - */ - configureSearchOptions(options) { + configureSearchOptions(options: Partial): void { Object.assign(this.searchOptions, options); this.reset(); this.searchInput.value = this.searchOptions.initialValue; } - setupListeners() { + setupListeners(): void { this.elem.addEventListener('click', this.onClick.bind(this)); let lastSearch = 0; @@ -67,7 +73,7 @@ export class EntitySelector extends Component { }); // Keyboard navigation - onChildEvent(this.$el, '[data-entity-type]', 'keydown', event => { + onChildEvent(this.$el, '[data-entity-type]', 'keydown', ((event: KeyboardEvent) => { if (event.ctrlKey && event.code === 'Enter') { const form = this.$el.closest('form'); if (form) { @@ -83,7 +89,7 @@ export class EntitySelector extends Component { if (event.code === 'ArrowUp') { this.focusAdjacent(false); } - }); + }) as (event: Event) => void); this.searchInput.addEventListener('keydown', event => { if (event.code === 'ArrowDown') { @@ -93,10 +99,10 @@ export class EntitySelector extends Component { } focusAdjacent(forward = true) { - const items = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]')); + const items: (Element|null)[] = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]')); const selectedIndex = items.indexOf(document.activeElement); const newItem = items[selectedIndex + (forward ? 1 : -1)] || items[0]; - if (newItem) { + if (newItem instanceof HTMLElement) { newItem.focus(); } } @@ -132,7 +138,7 @@ export class EntitySelector extends Component { } window.$http.get(this.searchUrl()).then(resp => { - this.resultsContainer.innerHTML = resp.data; + this.resultsContainer.innerHTML = resp.data as string; this.hideLoading(); }); } @@ -142,7 +148,7 @@ export class EntitySelector extends Component { return `${this.searchOptions.searchEndpoint}?${query}`; } - searchEntities(searchTerm) { + searchEntities(searchTerm: string) { if (!this.searchOptions.searchEndpoint) { throw new Error('Search endpoint not set for entity-selector load'); } @@ -150,7 +156,7 @@ export class EntitySelector extends Component { this.input.value = ''; const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`; window.$http.get(url).then(resp => { - this.resultsContainer.innerHTML = resp.data; + this.resultsContainer.innerHTML = resp.data as string; this.hideLoading(); }); } @@ -162,16 +168,16 @@ export class EntitySelector extends Component { return answer; } - onClick(event) { - const listItem = event.target.closest('[data-entity-type]'); - if (listItem) { + onClick(event: MouseEvent) { + const listItem = (event.target as HTMLElement).closest('[data-entity-type]'); + if (listItem instanceof HTMLElement) { event.preventDefault(); event.stopPropagation(); this.selectItem(listItem); } } - selectItem(item) { + selectItem(item: HTMLElement): void { const isDblClick = this.isDoubleClick(); const type = item.getAttribute('data-entity-type'); const id = item.getAttribute('data-entity-id'); @@ -180,14 +186,14 @@ export class EntitySelector extends Component { this.unselectAll(); this.input.value = isSelected ? `${type}:${id}` : ''; - const link = item.getAttribute('href'); - const name = item.querySelector('.entity-list-item-name').textContent; - const data = {id: Number(id), name, link}; + const link = item.getAttribute('href') || ''; + const name = item.querySelector('.entity-list-item-name')?.textContent || ''; + const data: EntitySelectorEntity = {id: Number(id), name, link}; if (isSelected) { item.classList.add('selected'); } else { - window.$events.emit('entity-select-change', null); + window.$events.emit('entity-select-change'); } if (!isDblClick && !isSelected) return; @@ -200,7 +206,7 @@ export class EntitySelector extends Component { } } - confirmSelection(data) { + confirmSelection(data: EntitySelectorEntity) { window.$events.emit('entity-select-confirm', data); } diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js index c8108ab28..84ba333f9 100644 --- a/resources/js/components/image-manager.js +++ b/resources/js/components/image-manager.js @@ -127,6 +127,10 @@ export class ImageManager extends Component { }); } + /** + * @param {({ thumbs: { display: string; }; url: string; name: string; }) => void} callback + * @param {String} type + */ show(callback, type = 'gallery') { this.resetAll(); diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index a0bb7a55b..8334ebb8a 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -1,8 +1,9 @@ import {Component} from './component'; import {getLoading, htmlToDom} from '../services/dom'; -import {buildForInput} from '../wysiwyg-tinymce/config'; import {PageCommentReference} from "./page-comment-reference"; import {HttpError} from "../services/http"; +import {SimpleWysiwygEditorInterface} from "../wysiwyg"; +import {el} from "../wysiwyg/utils/dom"; export interface PageCommentReplyEventData { id: string; // ID of comment being replied to @@ -21,8 +22,7 @@ export class PageComment extends Component { protected updatedText!: string; protected archiveText!: string; - protected wysiwygEditor: any = null; - protected wysiwygLanguage!: string; + protected wysiwygEditor: SimpleWysiwygEditorInterface|null = null; protected wysiwygTextDirection!: string; protected container!: HTMLElement; @@ -44,7 +44,6 @@ export class PageComment extends Component { this.archiveText = this.$opts.archiveText; // Editor reference and text options - this.wysiwygLanguage = this.$opts.wysiwygLanguage; this.wysiwygTextDirection = this.$opts.wysiwygTextDirection; // Element references @@ -90,7 +89,7 @@ export class PageComment extends Component { this.form.toggleAttribute('hidden', !show); } - protected startEdit() : void { + protected async startEdit(): Promise { this.toggleEditMode(true); if (this.wysiwygEditor) { @@ -98,21 +97,20 @@ export class PageComment extends Component { return; } - const config = buildForInput({ - language: this.wysiwygLanguage, - containerElement: this.input, + type WysiwygModule = typeof import('../wysiwyg'); + const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule; + const editorContent = this.input.value; + const container = el('div', {class: 'comment-editor-container'}); + this.input.parentElement?.appendChild(container); + this.input.hidden = true; + + this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, editorContent, { darkMode: document.documentElement.classList.contains('dark-mode'), - textDirection: this.wysiwygTextDirection, - drawioUrl: '', - pageId: 0, - translations: {}, - translationMap: (window as unknown as Record).editor_translations, + textDirection: this.$opts.textDirection, + translations: (window as unknown as Record).editor_translations, }); - (window as unknown as {tinymce: {init: (arg0: Object) => Promise}}).tinymce.init(config).then(editors => { - this.wysiwygEditor = editors[0]; - setTimeout(() => this.wysiwygEditor.focus(), 50); - }); + this.wysiwygEditor.focus(); } protected async update(event: Event): Promise { @@ -121,7 +119,7 @@ export class PageComment extends Component { this.form.toggleAttribute('hidden', true); const reqData = { - html: this.wysiwygEditor.getContent(), + html: await this.wysiwygEditor?.getContentAsHtml() || '', }; try { diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts index 5c1cd014c..a1eeda1f9 100644 --- a/resources/js/components/page-comments.ts +++ b/resources/js/components/page-comments.ts @@ -1,10 +1,11 @@ import {Component} from './component'; import {getLoading, htmlToDom} from '../services/dom'; -import {buildForInput} from '../wysiwyg-tinymce/config'; import {Tabs} from "./tabs"; import {PageCommentReference} from "./page-comment-reference"; import {scrollAndHighlightElement} from "../services/util"; import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment"; +import {el} from "../wysiwyg/utils/dom"; +import {SimpleWysiwygEditorInterface} from "../wysiwyg"; export class PageComments extends Component { @@ -28,9 +29,8 @@ export class PageComments extends Component { private hideFormButton!: HTMLElement; private removeReplyToButton!: HTMLElement; private removeReferenceButton!: HTMLElement; - private wysiwygLanguage!: string; private wysiwygTextDirection!: string; - private wysiwygEditor: any = null; + private wysiwygEditor: SimpleWysiwygEditorInterface|null = null; private createdText!: string; private countText!: string; private archivedCountText!: string; @@ -63,7 +63,6 @@ export class PageComments extends Component { this.removeReferenceButton = this.$refs.removeReferenceButton; // WYSIWYG options - this.wysiwygLanguage = this.$opts.wysiwygLanguage; this.wysiwygTextDirection = this.$opts.wysiwygTextDirection; // Translations @@ -107,7 +106,7 @@ export class PageComments extends Component { } } - protected saveComment(event: SubmitEvent): void { + protected async saveComment(event: SubmitEvent): Promise { event.preventDefault(); event.stopPropagation(); @@ -117,7 +116,7 @@ export class PageComments extends Component { this.form.toggleAttribute('hidden', true); const reqData = { - html: this.wysiwygEditor.getContent(), + html: (await this.wysiwygEditor?.getContentAsHtml()) || '', parent_id: this.parentId || null, content_ref: this.contentReference, }; @@ -189,27 +188,25 @@ export class PageComments extends Component { this.addButtonContainer.toggleAttribute('hidden', false); } - protected loadEditor(): void { + protected async loadEditor(): Promise { if (this.wysiwygEditor) { this.wysiwygEditor.focus(); return; } - const config = buildForInput({ - language: this.wysiwygLanguage, - containerElement: this.formInput, + type WysiwygModule = typeof import('../wysiwyg'); + const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule; + const container = el('div', {class: 'comment-editor-container'}); + this.formInput.parentElement?.appendChild(container); + this.formInput.hidden = true; + + this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, '

', { darkMode: document.documentElement.classList.contains('dark-mode'), textDirection: this.wysiwygTextDirection, - drawioUrl: '', - pageId: 0, - translations: {}, - translationMap: (window as unknown as Record).editor_translations, + translations: (window as unknown as Record).editor_translations, }); - (window as unknown as {tinymce: {init: (arg0: Object) => Promise}}).tinymce.init(config).then(editors => { - this.wysiwygEditor = editors[0]; - setTimeout(() => this.wysiwygEditor.focus(), 50); - }); + this.wysiwygEditor.focus(); } protected removeEditor(): void { diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index 81378e944..542453504 100644 --- a/resources/js/components/page-editor.js +++ b/resources/js/components/page-editor.js @@ -25,6 +25,7 @@ export class PageEditor extends Component { this.draftDisplayIcon = this.$refs.draftDisplayIcon; this.changelogInput = this.$refs.changelogInput; this.changelogDisplay = this.$refs.changelogDisplay; + this.changelogCounter = this.$refs.changelogCounter; this.changeEditorButtons = this.$manyRefs.changeEditor || []; this.switchDialogContainer = this.$refs.switchDialog; this.deleteDraftDialogContainer = this.$refs.deleteDraftDialog; @@ -75,7 +76,11 @@ export class PageEditor extends Component { // Changelog controls const updateChangelogDebounced = debounce(this.updateChangelogDisplay.bind(this), 300, false); - this.changelogInput.addEventListener('input', updateChangelogDebounced); + this.changelogInput.addEventListener('input', () => { + const count = this.changelogInput.value.length; + this.changelogCounter.innerText = `${count} / 180`; + updateChangelogDebounced(); + }); // Draft Controls onSelect(this.saveDraftButton, this.saveDraft.bind(this)); diff --git a/resources/js/components/tri-layout.js b/resources/js/components/tri-layout.ts similarity index 57% rename from resources/js/components/tri-layout.js rename to resources/js/components/tri-layout.ts index be9388e8d..40a2d3691 100644 --- a/resources/js/components/tri-layout.js +++ b/resources/js/components/tri-layout.ts @@ -1,18 +1,22 @@ import {Component} from './component'; export class TriLayout extends Component { + private container!: HTMLElement; + private tabs!: HTMLElement[]; + private sidebarScrollContainers!: HTMLElement[]; - setup() { + private lastLayoutType = 'none'; + private onDestroy: (()=>void)|null = null; + private scrollCache: Record = { + content: 0, + info: 0, + }; + private lastTabShown = 'content'; + + setup(): void { this.container = this.$refs.container; this.tabs = this.$manyRefs.tab; - - this.lastLayoutType = 'none'; - this.onDestroy = null; - this.scrollCache = { - content: 0, - info: 0, - }; - this.lastTabShown = 'content'; + this.sidebarScrollContainers = this.$manyRefs.sidebarScrollContainer; // Bind any listeners this.mobileTabClick = this.mobileTabClick.bind(this); @@ -22,9 +26,11 @@ export class TriLayout extends Component { window.addEventListener('resize', () => { this.updateLayout(); }, {passive: true}); + + this.setupSidebarScrollHandlers(); } - updateLayout() { + updateLayout(): void { let newLayout = 'tablet'; if (window.innerWidth <= 1000) newLayout = 'mobile'; if (window.innerWidth > 1400) newLayout = 'desktop'; @@ -56,16 +62,15 @@ export class TriLayout extends Component { }; } - setupDesktop() { + setupDesktop(): void { // } /** * Action to run when the mobile info toggle bar is clicked/tapped - * @param event */ - mobileTabClick(event) { - const {tab} = event.target.dataset; + mobileTabClick(event: MouseEvent): void { + const tab = (event.target as HTMLElement).dataset.tab || ''; this.showTab(tab); } @@ -73,16 +78,14 @@ export class TriLayout extends Component { * Show the content tab. * Used by the page-display component. */ - showContent() { + showContent(): void { this.showTab('content', false); } /** * Show the given tab - * @param {String} tabName - * @param {Boolean }scroll */ - showTab(tabName, scroll = true) { + showTab(tabName: string, scroll: boolean = true): void { this.scrollCache[this.lastTabShown] = document.documentElement.scrollTop; // Set tab status @@ -97,7 +100,7 @@ export class TriLayout extends Component { // Set the scroll position from cache if (scroll) { - const pageHeader = document.querySelector('header'); + const pageHeader = document.querySelector('header') as HTMLElement; const defaultScrollTop = pageHeader.getBoundingClientRect().bottom; document.documentElement.scrollTop = this.scrollCache[tabName] || defaultScrollTop; setTimeout(() => { @@ -108,4 +111,30 @@ export class TriLayout extends Component { this.lastTabShown = tabName; } + setupSidebarScrollHandlers(): void { + for (const sidebar of this.sidebarScrollContainers) { + sidebar.addEventListener('scroll', () => this.handleSidebarScroll(sidebar), { + passive: true, + }); + this.handleSidebarScroll(sidebar); + } + + window.addEventListener('resize', () => { + for (const sidebar of this.sidebarScrollContainers) { + this.handleSidebarScroll(sidebar); + } + }); + } + + handleSidebarScroll(sidebar: HTMLElement): void { + const scrollable = sidebar.clientHeight !== sidebar.scrollHeight; + const atTop = sidebar.scrollTop === 0; + const atBottom = (sidebar.scrollTop + sidebar.clientHeight) === sidebar.scrollHeight; + + if (sidebar.parentElement) { + sidebar.parentElement.classList.toggle('scroll-away-from-top', !atTop && scrollable); + sidebar.parentElement.classList.toggle('scroll-away-from-bottom', !atBottom && scrollable); + } + } + } diff --git a/resources/js/components/wysiwyg-input.js b/resources/js/components/wysiwyg-input.js deleted file mode 100644 index aa21a6371..000000000 --- a/resources/js/components/wysiwyg-input.js +++ /dev/null @@ -1,23 +0,0 @@ -import {Component} from './component'; -import {buildForInput} from '../wysiwyg-tinymce/config'; - -export class WysiwygInput extends Component { - - setup() { - this.elem = this.$el; - - const config = buildForInput({ - language: this.$opts.language, - containerElement: this.elem, - darkMode: document.documentElement.classList.contains('dark-mode'), - textDirection: this.$opts.textDirection, - translations: {}, - translationMap: window.editor_translations, - }); - - window.tinymce.init(config).then(editors => { - this.editor = editors[0]; - }); - } - -} diff --git a/resources/js/components/wysiwyg-input.ts b/resources/js/components/wysiwyg-input.ts new file mode 100644 index 000000000..1d914adb9 --- /dev/null +++ b/resources/js/components/wysiwyg-input.ts @@ -0,0 +1,32 @@ +import {Component} from './component'; +import {el} from "../wysiwyg/utils/dom"; +import {SimpleWysiwygEditorInterface} from "../wysiwyg"; + +export class WysiwygInput extends Component { + private elem!: HTMLTextAreaElement; + private wysiwygEditor!: SimpleWysiwygEditorInterface; + private textDirection!: string; + + async setup() { + this.elem = this.$el as HTMLTextAreaElement; + this.textDirection = this.$opts.textDirection; + + type WysiwygModule = typeof import('../wysiwyg'); + const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule; + const container = el('div', {class: 'basic-editor-container'}); + this.elem.parentElement?.appendChild(container); + this.elem.hidden = true; + + this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, this.elem.value, { + darkMode: document.documentElement.classList.contains('dark-mode'), + textDirection: this.textDirection, + translations: (window as unknown as Record).editor_translations, + }); + + this.wysiwygEditor.onChange(() => { + this.wysiwygEditor.getContentAsHtml().then(html => { + this.elem.value = html; + }); + }); + } +} diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts index b637c97c1..239f4b924 100644 --- a/resources/js/global.d.ts +++ b/resources/js/global.d.ts @@ -15,4 +15,6 @@ declare global { baseUrl: (path: string) => string; importVersioned: (module: string) => Promise; } -} \ No newline at end of file +} + +export type CodeModule = (typeof import('./code/index.mjs')); \ No newline at end of file diff --git a/resources/js/markdown/actions.js b/resources/js/markdown/actions.ts similarity index 53% rename from resources/js/markdown/actions.js rename to resources/js/markdown/actions.ts index e99bbf3e1..36d21ab1d 100644 --- a/resources/js/markdown/actions.js +++ b/resources/js/markdown/actions.ts @@ -1,20 +1,29 @@ -import * as DrawIO from '../services/drawio.ts'; +import * as DrawIO from '../services/drawio'; +import {MarkdownEditor} from "./index.mjs"; +import {EntitySelectorPopup, ImageManager} from "../components"; +import {MarkdownEditorInputSelection} from "./inputs/interface"; + +interface ImageManagerImage { + id: number; + name: string; + thumbs: { display: string; }; + url: string; +} export class Actions { - /** - * @param {MarkdownEditor} editor - */ - constructor(editor) { + protected readonly editor: MarkdownEditor; + protected lastContent: { html: string; markdown: string } = { + html: '', + markdown: '', + }; + + constructor(editor: MarkdownEditor) { this.editor = editor; - this.lastContent = { - html: '', - markdown: '', - }; } updateAndRender() { - const content = this.#getText(); + const content = this.editor.input.getText(); this.editor.config.inputEl.value = content; const html = this.editor.markdown.render(content); @@ -30,45 +39,42 @@ export class Actions { } showImageInsert() { - /** @type {ImageManager} * */ - const imageManager = window.$components.first('image-manager'); + const imageManager = window.$components.first('image-manager') as ImageManager; - imageManager.show(image => { + imageManager.show((image: ImageManagerImage) => { const imageUrl = image.thumbs?.display || image.url; - const selectedText = this.#getSelectionText(); + const selectedText = this.editor.input.getSelectionText(); const newText = `[![${selectedText || image.name}](${imageUrl})](${image.url})`; this.#replaceSelection(newText, newText.length); }, 'gallery'); } insertImage() { - const newText = `![${this.#getSelectionText()}](http://)`; + const newText = `![${this.editor.input.getSelectionText()}](http://)`; this.#replaceSelection(newText, newText.length - 1); } insertLink() { - const selectedText = this.#getSelectionText(); + const selectedText = this.editor.input.getSelectionText(); const newText = `[${selectedText}]()`; const cursorPosDiff = (selectedText === '') ? -3 : -1; this.#replaceSelection(newText, newText.length + cursorPosDiff); } showImageManager() { - const selectionRange = this.#getSelectionRange(); - /** @type {ImageManager} * */ - const imageManager = window.$components.first('image-manager'); - imageManager.show(image => { + const selectionRange = this.editor.input.getSelection(); + const imageManager = window.$components.first('image-manager') as ImageManager; + imageManager.show((image: ImageManagerImage) => { this.#insertDrawing(image, selectionRange); }, 'drawio'); } // Show the popup link selector and insert a link when finished showLinkSelector() { - const selectionRange = this.#getSelectionRange(); + const selectionRange = this.editor.input.getSelection(); - /** @type {EntitySelectorPopup} * */ - const selector = window.$components.first('entity-selector-popup'); - const selectionText = this.#getSelectionText(selectionRange); + const selector = window.$components.first('entity-selector-popup') as EntitySelectorPopup; + const selectionText = this.editor.input.getSelectionText(selectionRange); selector.show(entity => { const selectedText = selectionText || entity.name; const newText = `[${selectedText}](${entity.link})`; @@ -86,7 +92,7 @@ export class Actions { const url = this.editor.config.drawioUrl; if (!url) return; - const selectionRange = this.#getSelectionRange(); + const selectionRange = this.editor.input.getSelection(); DrawIO.show(url, () => Promise.resolve(''), async pngData => { const data = { @@ -96,7 +102,7 @@ export class Actions { try { const resp = await window.$http.post('/images/drawio', data); - this.#insertDrawing(resp.data, selectionRange); + this.#insertDrawing(resp.data as ImageManagerImage, selectionRange); DrawIO.close(); } catch (err) { this.handleDrawingUploadError(err); @@ -105,20 +111,23 @@ export class Actions { }); } - #insertDrawing(image, originalSelectionRange) { + #insertDrawing(image: ImageManagerImage, originalSelectionRange: MarkdownEditorInputSelection) { const newText = `
`; this.#replaceSelection(newText, newText.length, originalSelectionRange); } // Show draw.io if enabled and handle save. - editDrawing(imgContainer) { + editDrawing(imgContainer: HTMLElement) { const {drawioUrl} = this.editor.config; if (!drawioUrl) { return; } - const selectionRange = this.#getSelectionRange(); - const drawingId = imgContainer.getAttribute('drawio-diagram'); + const selectionRange = this.editor.input.getSelection(); + const drawingId = imgContainer.getAttribute('drawio-diagram') || ''; + if (!drawingId) { + return; + } DrawIO.show(drawioUrl, () => DrawIO.load(drawingId), async pngData => { const data = { @@ -128,14 +137,15 @@ export class Actions { try { const resp = await window.$http.post('/images/drawio', data); - const newText = `
`; - const newContent = this.#getText().split('\n').map(line => { + const image = resp.data as ImageManagerImage; + const newText = `
`; + const newContent = this.editor.input.getText().split('\n').map(line => { if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) { return newText; } return line; }).join('\n'); - this.#setText(newContent, selectionRange); + this.editor.input.setText(newContent, selectionRange); DrawIO.close(); } catch (err) { this.handleDrawingUploadError(err); @@ -144,7 +154,7 @@ export class Actions { }); } - handleDrawingUploadError(error) { + handleDrawingUploadError(error: any): void { if (error.status === 413) { window.$events.emit('error', this.editor.config.text.serverUploadLimit); } else { @@ -162,91 +172,71 @@ export class Actions { } // Scroll to a specified text - scrollToText(searchText) { + scrollToText(searchText: string): void { if (!searchText) { return; } - const text = this.editor.cm.state.doc; - let lineCount = 1; - let scrollToLine = -1; - for (const line of text.iterLines()) { - if (line.includes(searchText)) { - scrollToLine = lineCount; - break; - } - lineCount += 1; + const lineRange = this.editor.input.searchForLineContaining(searchText); + if (lineRange) { + this.editor.input.setSelection(lineRange, true); + this.editor.input.focus(); } - - if (scrollToLine === -1) { - return; - } - - const line = text.line(scrollToLine); - this.#setSelection(line.from, line.to, true); - this.focus(); } focus() { - if (!this.editor.cm.hasFocus) { - this.editor.cm.focus(); - } + this.editor.input.focus(); } /** * Insert content into the editor. - * @param {String} content */ - insertContent(content) { + insertContent(content: string) { this.#replaceSelection(content, content.length); } /** * Prepend content to the editor. - * @param {String} content */ - prependContent(content) { + prependContent(content: string): void { content = this.#cleanTextForEditor(content); - const selectionRange = this.#getSelectionRange(); + const selectionRange = this.editor.input.getSelection(); const selectFrom = selectionRange.from + content.length + 1; - this.#dispatchChange(0, 0, `${content}\n`, selectFrom); - this.focus(); + this.editor.input.spliceText(0, 0, `${content}\n`, {from: selectFrom}); + this.editor.input.focus(); } /** * Append content to the editor. - * @param {String} content */ - appendContent(content) { + appendContent(content: string): void { content = this.#cleanTextForEditor(content); - this.#dispatchChange(this.editor.cm.state.doc.length, `\n${content}`); - this.focus(); + this.editor.input.appendText(content); + this.editor.input.focus(); } /** * Replace the editor's contents - * @param {String} content */ - replaceContent(content) { - this.#setText(content); + replaceContent(content: string): void { + this.editor.input.setText(content); } /** * Replace the start of the line * @param {String} newStart */ - replaceLineStart(newStart) { - const selectionRange = this.#getSelectionRange(); - const line = this.editor.cm.state.doc.lineAt(selectionRange.from); - - const lineContent = line.text; + replaceLineStart(newStart: string): void { + const selectionRange = this.editor.input.getSelection(); + const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from); + const lineContent = this.editor.input.getSelectionText(lineRange); const lineStart = lineContent.split(' ')[0]; // Remove symbol if already set if (lineStart === newStart) { const newLineContent = lineContent.replace(`${newStart} `, ''); const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length); - this.#dispatchChange(line.from, line.to, newLineContent, selectFrom); + this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectFrom}); return; } @@ -259,48 +249,46 @@ export class Actions { } const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length); - this.#dispatchChange(line.from, line.to, newLineContent, selectFrom); + this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectFrom}); } /** * Wrap the selection in the given contents start and end contents. - * @param {String} start - * @param {String} end */ - wrapSelection(start, end) { - const selectRange = this.#getSelectionRange(); - const selectionText = this.#getSelectionText(selectRange); + wrapSelection(start: string, end: string): void { + const selectRange = this.editor.input.getSelection(); + const selectionText = this.editor.input.getSelectionText(selectRange); if (!selectionText) { this.#wrapLine(start, end); return; } - let newSelectionText = selectionText; - let newRange; + let newSelectionText: string; + let newRange = {from: selectRange.from, to: selectRange.to}; if (selectionText.startsWith(start) && selectionText.endsWith(end)) { newSelectionText = selectionText.slice(start.length, selectionText.length - end.length); - newRange = selectRange.extend(selectRange.from, selectRange.to - (start.length + end.length)); + newRange.to = selectRange.to - (start.length + end.length); } else { newSelectionText = `${start}${selectionText}${end}`; - newRange = selectRange.extend(selectRange.from, selectRange.to + (start.length + end.length)); + newRange.to = selectRange.to + (start.length + end.length); } - this.#dispatchChange( + this.editor.input.spliceText( selectRange.from, selectRange.to, newSelectionText, - newRange.anchor, - newRange.head, + newRange, ); } replaceLineStartForOrderedList() { - const selectionRange = this.#getSelectionRange(); - const line = this.editor.cm.state.doc.lineAt(selectionRange.from); - const prevLine = this.editor.cm.state.doc.line(line.number - 1); + const selectionRange = this.editor.input.getSelection(); + const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from); + const prevLineRange = this.editor.input.getLineRangeFromPosition(lineRange.from - 1); + const prevLineText = this.editor.input.getSelectionText(prevLineRange); - const listMatch = prevLine.text.match(/^(\s*)(\d)([).])\s/) || []; + const listMatch = prevLineText.match(/^(\s*)(\d)([).])\s/) || []; const number = (Number(listMatch[2]) || 0) + 1; const whiteSpace = listMatch[1] || ''; @@ -315,45 +303,46 @@ export class Actions { * Creates a callout block if none existing, and removes it if cycling past the danger type. */ cycleCalloutTypeAtSelection() { - const selectionRange = this.#getSelectionRange(); - const line = this.editor.cm.state.doc.lineAt(selectionRange.from); + const selectionRange = this.editor.input.getSelection(); + const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from); + const lineText = this.editor.input.getSelectionText(lineRange); const formats = ['info', 'success', 'warning', 'danger']; const joint = formats.join('|'); const regex = new RegExp(`class="((${joint})\\s+callout|callout\\s+(${joint}))"`, 'i'); - const matches = regex.exec(line.text); + const matches = regex.exec(lineText); const format = (matches ? (matches[2] || matches[3]) : '').toLowerCase(); if (format === formats[formats.length - 1]) { this.#wrapLine(`

`, '

'); } else if (format === '') { this.#wrapLine('

', '

'); - } else { + } else if (matches) { const newFormatIndex = formats.indexOf(format) + 1; const newFormat = formats[newFormatIndex]; - const newContent = line.text.replace(matches[0], matches[0].replace(format, newFormat)); - const lineDiff = newContent.length - line.text.length; - this.#dispatchChange( - line.from, - line.to, + const newContent = lineText.replace(matches[0], matches[0].replace(format, newFormat)); + const lineDiff = newContent.length - lineText.length; + const anchor = Math.min(selectionRange.from, selectionRange.to); + const head = Math.max(selectionRange.from, selectionRange.to); + this.editor.input.spliceText( + lineRange.from, + lineRange.to, newContent, - selectionRange.anchor + lineDiff, - selectionRange.head + lineDiff, + {from: anchor + lineDiff, to: head + lineDiff} ); } } - syncDisplayPosition(event) { + syncDisplayPosition(event: Event): void { // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html - const scrollEl = event.target; + const scrollEl = event.target as HTMLElement; const atEnd = Math.abs(scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 1; if (atEnd) { this.editor.display.scrollToIndex(-1); return; } - const blockInfo = this.editor.cm.lineBlockAtHeight(scrollEl.scrollTop); - const range = this.editor.cm.state.sliceDoc(0, blockInfo.from); + const range = this.editor.input.getTextAboveView(); const parser = new DOMParser(); const doc = parser.parseFromString(this.editor.markdown.render(range), 'text/html'); const totalLines = doc.documentElement.querySelectorAll('body > *'); @@ -363,54 +352,48 @@ export class Actions { /** * Fetch and insert the template of the given ID. * The page-relative position provided can be used to determine insert location if possible. - * @param {String} templateId - * @param {Number} posX - * @param {Number} posY */ - async insertTemplate(templateId, posX, posY) { - const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false); - const {data} = await window.$http.get(`/templates/${templateId}`); - const content = data.markdown || data.html; - this.#dispatchChange(cursorPos, cursorPos, content, cursorPos); + async insertTemplate(templateId: string, event: MouseEvent): Promise { + const cursorPos = this.editor.input.eventToPosition(event).from; + const responseData = (await window.$http.get(`/templates/${templateId}`)).data as {markdown: string, html: string}; + const content = responseData.markdown || responseData.html; + this.editor.input.spliceText(cursorPos, cursorPos, content, {from: cursorPos}); } /** * Insert multiple images from the clipboard from an event at the provided * screen coordinates (Typically form a paste event). - * @param {File[]} images - * @param {Number} posX - * @param {Number} posY */ - insertClipboardImages(images, posX, posY) { - const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false); + insertClipboardImages(images: File[], event: MouseEvent): void { + const cursorPos = this.editor.input.eventToPosition(event).from; for (const image of images) { this.uploadImage(image, cursorPos); } } /** - * Handle image upload and add image into markdown content - * @param {File} file - * @param {?Number} position + * Handle image upload and add image into Markdown content */ - async uploadImage(file, position = null) { + async uploadImage(file: File, position: number|null = null): Promise { if (file === null || file.type.indexOf('image') !== 0) return; let ext = 'png'; if (position === null) { - position = this.#getSelectionRange().from; + position = this.editor.input.getSelection().from; } if (file.name) { const fileNameMatches = file.name.match(/\.(.+)$/); - if (fileNameMatches.length > 1) ext = fileNameMatches[1]; + if (fileNameMatches && fileNameMatches.length > 1) { + ext = fileNameMatches[1]; + } } // Insert image into markdown const id = `image-${Math.random().toString(16).slice(2)}`; const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`); const placeHolderText = `![](${placeholderImage})`; - this.#dispatchChange(position, position, placeHolderText, position); + this.editor.input.spliceText(position, position, placeHolderText, {from: position}); const remoteFilename = `image-${Date.now()}.${ext}`; const formData = new FormData(); @@ -418,105 +401,53 @@ export class Actions { formData.append('uploaded_to', this.editor.config.pageId); try { - const {data} = await window.$http.post('/images/gallery', formData); - const newContent = `[![](${data.thumbs.display})](${data.url})`; + const image = (await window.$http.post('/images/gallery', formData)).data as ImageManagerImage; + const newContent = `[![](${image.thumbs.display})](${image.url})`; this.#findAndReplaceContent(placeHolderText, newContent); - } catch (err) { + } catch (err: any) { window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError); this.#findAndReplaceContent(placeHolderText, ''); console.error(err); } } - /** - * Get the current text of the editor instance. - * @return {string} - */ - #getText() { - return this.editor.cm.state.doc.toString(); - } - - /** - * Set the text of the current editor instance. - * @param {String} text - * @param {?SelectionRange} selectionRange - */ - #setText(text, selectionRange = null) { - selectionRange = selectionRange || this.#getSelectionRange(); - const newDoc = this.editor.cm.state.toText(text); - const newSelectFrom = Math.min(selectionRange.from, newDoc.length); - const scrollTop = this.editor.cm.scrollDOM.scrollTop; - this.#dispatchChange(0, this.editor.cm.state.doc.length, text, newSelectFrom); - this.focus(); - window.requestAnimationFrame(() => { - this.editor.cm.scrollDOM.scrollTop = scrollTop; - }); - } - /** * Replace the current selection and focus the editor. * Takes an offset for the cursor, after the change, relative to the start of the provided string. * Can be provided a selection range to use instead of the current selection range. - * @param {String} newContent - * @param {Number} cursorOffset - * @param {?SelectionRange} selectionRange */ - #replaceSelection(newContent, cursorOffset = 0, selectionRange = null) { - selectionRange = selectionRange || this.editor.cm.state.selection.main; - const selectFrom = selectionRange.from + cursorOffset; - this.#dispatchChange(selectionRange.from, selectionRange.to, newContent, selectFrom); - this.focus(); - } - - /** - * Get the text content of the main current selection. - * @param {SelectionRange} selectionRange - * @return {string} - */ - #getSelectionText(selectionRange = null) { - selectionRange = selectionRange || this.#getSelectionRange(); - return this.editor.cm.state.sliceDoc(selectionRange.from, selectionRange.to); - } - - /** - * Get the range of the current main selection. - * @return {SelectionRange} - */ - #getSelectionRange() { - return this.editor.cm.state.selection.main; + #replaceSelection(newContent: string, offset: number = 0, selection: MarkdownEditorInputSelection|null = null) { + selection = selection || this.editor.input.getSelection(); + const selectFrom = selection.from + offset; + this.editor.input.spliceText(selection.from, selection.to, newContent, {from: selectFrom, to: selectFrom}); + this.editor.input.focus(); } /** * Cleans the given text to work with the editor. * Standardises line endings to what's expected. - * @param {String} text - * @return {String} */ - #cleanTextForEditor(text) { + #cleanTextForEditor(text: string): string { return text.replace(/\r\n|\r/g, '\n'); } /** * Find and replace the first occurrence of [search] with [replace] - * @param {String} search - * @param {String} replace */ - #findAndReplaceContent(search, replace) { - const newText = this.#getText().replace(search, replace); - this.#setText(newText); + #findAndReplaceContent(search: string, replace: string): void { + const newText = this.editor.input.getText().replace(search, replace); + this.editor.input.setText(newText); } /** * Wrap the line in the given start and end contents. - * @param {String} start - * @param {String} end */ - #wrapLine(start, end) { - const selectionRange = this.#getSelectionRange(); - const line = this.editor.cm.state.doc.lineAt(selectionRange.from); - const lineContent = line.text; - let newLineContent; - let lineOffset = 0; + #wrapLine(start: string, end: string): void { + const selectionRange = this.editor.input.getSelection(); + const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from); + const lineContent = this.editor.input.getSelectionText(lineRange); + let newLineContent: string; + let lineOffset: number; if (lineContent.startsWith(start) && lineContent.endsWith(end)) { newLineContent = lineContent.slice(start.length, lineContent.length - end.length); @@ -526,42 +457,7 @@ export class Actions { lineOffset = start.length; } - this.#dispatchChange(line.from, line.to, newLineContent, selectionRange.from + lineOffset); - } - - /** - * Dispatch changes to the editor. - * @param {Number} from - * @param {?Number} to - * @param {?String} text - * @param {?Number} selectFrom - * @param {?Number} selectTo - */ - #dispatchChange(from, to = null, text = null, selectFrom = null, selectTo = null) { - const tr = {changes: {from, to, insert: text}}; - - if (selectFrom) { - tr.selection = {anchor: selectFrom}; - if (selectTo) { - tr.selection.head = selectTo; - } - } - - this.editor.cm.dispatch(tr); - } - - /** - * Set the current selection range. - * Optionally will scroll the new range into view. - * @param {Number} from - * @param {Number} to - * @param {Boolean} scrollIntoView - */ - #setSelection(from, to, scrollIntoView = false) { - this.editor.cm.dispatch({ - selection: {anchor: from, head: to}, - scrollIntoView, - }); + this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectionRange.from + lineOffset}); } } diff --git a/resources/js/markdown/codemirror.ts b/resources/js/markdown/codemirror.ts new file mode 100644 index 000000000..1ae018477 --- /dev/null +++ b/resources/js/markdown/codemirror.ts @@ -0,0 +1,54 @@ +import {EditorView, KeyBinding, ViewUpdate} from "@codemirror/view"; +import {CodeModule} from "../global"; +import {MarkdownEditorEventMap} from "./dom-handlers"; +import {MarkdownEditorShortcutMap} from "./shortcuts"; + +/** + * Convert editor shortcuts to CodeMirror keybinding format. + */ +export function shortcutsToKeyBindings(shortcuts: MarkdownEditorShortcutMap): KeyBinding[] { + const keyBindings = []; + + const wrapAction = (action: () => void) => () => { + action(); + return true; + }; + + for (const [shortcut, action] of Object.entries(shortcuts)) { + keyBindings.push({key: shortcut, run: wrapAction(action), preventDefault: true}); + } + + return keyBindings; +} + +/** + * Initiate the codemirror instance for the Markdown editor. + */ +export async function init( + input: HTMLTextAreaElement, + shortcuts: MarkdownEditorShortcutMap, + domEventHandlers: MarkdownEditorEventMap, + onChange: () => void +): Promise { + const Code = await window.importVersioned('code') as CodeModule; + + function onViewUpdate(v: ViewUpdate) { + if (v.docChanged) { + onChange(); + } + } + + const cm = Code.markdownEditor( + input, + onViewUpdate, + domEventHandlers, + shortcutsToKeyBindings(shortcuts), + ); + + // Add editor view to the window for easy access/debugging. + // Not part of official API/Docs + // @ts-ignore + window.mdEditorView = cm; + + return cm; +} diff --git a/resources/js/markdown/common-events.js b/resources/js/markdown/common-events.js deleted file mode 100644 index c3d803f70..000000000 --- a/resources/js/markdown/common-events.js +++ /dev/null @@ -1,32 +0,0 @@ -function getContentToInsert({html, markdown}) { - return markdown || html; -} - -/** - * @param {MarkdownEditor} editor - */ -export function listen(editor) { - window.$events.listen('editor::replace', eventContent => { - const markdown = getContentToInsert(eventContent); - editor.actions.replaceContent(markdown); - }); - - window.$events.listen('editor::append', eventContent => { - const markdown = getContentToInsert(eventContent); - editor.actions.appendContent(markdown); - }); - - window.$events.listen('editor::prepend', eventContent => { - const markdown = getContentToInsert(eventContent); - editor.actions.prependContent(markdown); - }); - - window.$events.listen('editor::insert', eventContent => { - const markdown = getContentToInsert(eventContent); - editor.actions.insertContent(markdown); - }); - - window.$events.listen('editor::focus', () => { - editor.actions.focus(); - }); -} diff --git a/resources/js/markdown/common-events.ts b/resources/js/markdown/common-events.ts new file mode 100644 index 000000000..4bfc4bb46 --- /dev/null +++ b/resources/js/markdown/common-events.ts @@ -0,0 +1,36 @@ +import {MarkdownEditor} from "./index.mjs"; + +export interface HtmlOrMarkdown { + html: string; + markdown: string; +} + +function getContentToInsert({html, markdown}: {html: string, markdown: string}): string { + return markdown || html; +} + +export function listenToCommonEvents(editor: MarkdownEditor): void { + window.$events.listen('editor::replace', (eventContent: HtmlOrMarkdown) => { + const markdown = getContentToInsert(eventContent); + editor.actions.replaceContent(markdown); + }); + + window.$events.listen('editor::append', (eventContent: HtmlOrMarkdown) => { + const markdown = getContentToInsert(eventContent); + editor.actions.appendContent(markdown); + }); + + window.$events.listen('editor::prepend', (eventContent: HtmlOrMarkdown) => { + const markdown = getContentToInsert(eventContent); + editor.actions.prependContent(markdown); + }); + + window.$events.listen('editor::insert', (eventContent: HtmlOrMarkdown) => { + const markdown = getContentToInsert(eventContent); + editor.actions.insertContent(markdown); + }); + + window.$events.listen('editor::focus', () => { + editor.actions.focus(); + }); +} diff --git a/resources/js/markdown/display.js b/resources/js/markdown/display.ts similarity index 52% rename from resources/js/markdown/display.js rename to resources/js/markdown/display.ts index 60be26b5f..3eb7e5c6a 100644 --- a/resources/js/markdown/display.js +++ b/resources/js/markdown/display.ts @@ -1,35 +1,36 @@ -import {patchDomFromHtmlString} from '../services/vdom.ts'; +import { patchDomFromHtmlString } from '../services/vdom'; +import {MarkdownEditor} from "./index.mjs"; export class Display { + protected editor: MarkdownEditor; + protected container: HTMLIFrameElement; + protected doc: Document | null = null; + protected lastDisplayClick: number = 0; - /** - * @param {MarkdownEditor} editor - */ - constructor(editor) { + constructor(editor: MarkdownEditor) { this.editor = editor; this.container = editor.config.displayEl; - this.doc = null; - this.lastDisplayClick = 0; - - if (this.container.contentDocument.readyState === 'complete') { + if (this.container.contentDocument?.readyState === 'complete') { this.onLoad(); } else { this.container.addEventListener('load', this.onLoad.bind(this)); } - this.updateVisibility(editor.settings.get('showPreview')); - editor.settings.onChange('showPreview', show => this.updateVisibility(show)); + this.updateVisibility(Boolean(editor.settings.get('showPreview'))); + editor.settings.onChange('showPreview', (show) => this.updateVisibility(Boolean(show))); } - updateVisibility(show) { - const wrap = this.container.closest('.markdown-editor-wrap'); - wrap.style.display = show ? null : 'none'; + protected updateVisibility(show: boolean): void { + const wrap = this.container.closest('.markdown-editor-wrap') as HTMLElement; + wrap.style.display = show ? '' : 'none'; } - onLoad() { + protected onLoad(): void { this.doc = this.container.contentDocument; + if (!this.doc) return; + this.loadStylesIntoDisplay(); this.doc.body.className = 'page-content'; @@ -37,20 +38,20 @@ export class Display { this.doc.addEventListener('click', this.onDisplayClick.bind(this)); } - /** - * @param {MouseEvent} event - */ - onDisplayClick(event) { + protected onDisplayClick(event: MouseEvent): void { const isDblClick = Date.now() - this.lastDisplayClick < 300; - const link = event.target.closest('a'); + const link = (event.target as Element).closest('a'); if (link !== null) { event.preventDefault(); - window.open(link.getAttribute('href')); + const href = link.getAttribute('href'); + if (href) { + window.open(href); + } return; } - const drawing = event.target.closest('[drawio-diagram]'); + const drawing = (event.target as Element).closest('[drawio-diagram]') as HTMLElement; if (drawing !== null && isDblClick) { this.editor.actions.editDrawing(drawing); return; @@ -59,10 +60,12 @@ export class Display { this.lastDisplayClick = Date.now(); } - loadStylesIntoDisplay() { + protected loadStylesIntoDisplay(): void { + if (!this.doc) return; + this.doc.documentElement.classList.add('markdown-editor-display'); - // Set display to be dark mode if parent is + // Set display to be dark mode if the parent is if (document.documentElement.classList.contains('dark-mode')) { this.doc.documentElement.style.backgroundColor = '#222'; this.doc.documentElement.classList.add('dark-mode'); @@ -71,24 +74,25 @@ export class Display { this.doc.head.innerHTML = ''; const styles = document.head.querySelectorAll('style,link[rel=stylesheet]'); for (const style of styles) { - const copy = style.cloneNode(true); + const copy = style.cloneNode(true) as HTMLElement; this.doc.head.appendChild(copy); } } /** * Patch the display DOM with the given HTML content. - * @param {String} html */ - patchWithHtml(html) { - const {body} = this.doc; + public patchWithHtml(html: string): void { + if (!this.doc) return; + + const { body } = this.doc; if (body.children.length === 0) { const wrap = document.createElement('div'); this.doc.body.append(wrap); } - const target = body.children[0]; + const target = body.children[0] as HTMLElement; patchDomFromHtmlString(target, html); } @@ -96,14 +100,16 @@ export class Display { /** * Scroll to the given block index within the display content. * Will scroll to the end if the index is -1. - * @param {Number} index */ - scrollToIndex(index) { - const elems = this.doc.body?.children[0]?.children; - if (elems && elems.length <= index) return; + public scrollToIndex(index: number): void { + const elems = this.doc?.body?.children[0]?.children; + if (!elems || elems.length <= index) return; const topElem = (index === -1) ? elems[elems.length - 1] : elems[index]; - topElem.scrollIntoView({block: 'start', inline: 'nearest', behavior: 'smooth'}); + (topElem as Element).scrollIntoView({ + block: 'start', + inline: 'nearest', + behavior: 'smooth' + }); } - -} +} \ No newline at end of file diff --git a/resources/js/markdown/codemirror.js b/resources/js/markdown/dom-handlers.ts similarity index 53% rename from resources/js/markdown/codemirror.js rename to resources/js/markdown/dom-handlers.ts index 664767605..37e1723de 100644 --- a/resources/js/markdown/codemirror.js +++ b/resources/js/markdown/dom-handlers.ts @@ -1,23 +1,11 @@ -import {provideKeyBindings} from './shortcuts'; -import {debounce} from '../services/util.ts'; -import {Clipboard} from '../services/clipboard.ts'; +import {Clipboard} from "../services/clipboard"; +import {MarkdownEditor} from "./index.mjs"; +import {debounce} from "../services/util"; -/** - * Initiate the codemirror instance for the markdown editor. - * @param {MarkdownEditor} editor - * @returns {Promise} - */ -export async function init(editor) { - const Code = await window.importVersioned('code'); - /** - * @param {ViewUpdate} v - */ - function onViewUpdate(v) { - if (v.docChanged) { - editor.actions.updateAndRender(); - } - } +export type MarkdownEditorEventMap = Record void>; + +export function getMarkdownDomEventHandlers(editor: MarkdownEditor): MarkdownEditorEventMap { const onScrollDebounced = debounce(editor.actions.syncDisplayPosition.bind(editor.actions), 100, false); let syncActive = editor.settings.get('scrollSync'); @@ -25,15 +13,19 @@ export async function init(editor) { syncActive = val; }); - const domEventHandlers = { + return { // Handle scroll to sync display view - scroll: event => syncActive && onScrollDebounced(event), + scroll: (event: Event) => syncActive && onScrollDebounced(event), // Handle image & content drag n drop - drop: event => { + drop: (event: DragEvent) => { + if (!event.dataTransfer) { + return; + } + const templateId = event.dataTransfer.getData('bookstack/template'); if (templateId) { event.preventDefault(); - editor.actions.insertTemplate(templateId, event.pageX, event.pageY); + editor.actions.insertTemplate(templateId, event); } const clipboard = new Clipboard(event.dataTransfer); @@ -41,16 +33,20 @@ export async function init(editor) { if (clipboardImages.length > 0) { event.stopPropagation(); event.preventDefault(); - editor.actions.insertClipboardImages(clipboardImages, event.pageX, event.pageY); + editor.actions.insertClipboardImages(clipboardImages, event); } }, // Handle dragover event to allow as drop-target in chrome - dragover: event => { + dragover: (event: DragEvent) => { event.preventDefault(); }, // Handle image paste - paste: event => { - const clipboard = new Clipboard(event.clipboardData || event.dataTransfer); + paste: (event: ClipboardEvent) => { + if (!event.clipboardData) { + return; + } + + const clipboard = new Clipboard(event.clipboardData); // Don't handle the event ourselves if no items exist of contains table-looking data if (!clipboard.hasItems() || clipboard.containsTabularData()) { @@ -63,17 +59,4 @@ export async function init(editor) { } }, }; - - const cm = Code.markdownEditor( - editor.config.inputEl, - onViewUpdate, - domEventHandlers, - provideKeyBindings(editor), - ); - - // Add editor view to window for easy access/debugging. - // Not part of official API/Docs - window.mdEditorView = cm; - - return cm; -} +} \ No newline at end of file diff --git a/resources/js/markdown/index.mjs b/resources/js/markdown/index.mjs deleted file mode 100644 index 46c35c850..000000000 --- a/resources/js/markdown/index.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import {Markdown} from './markdown'; -import {Display} from './display'; -import {Actions} from './actions'; -import {Settings} from './settings'; -import {listen} from './common-events'; -import {init as initCodemirror} from './codemirror'; - -/** - * Initiate a new markdown editor instance. - * @param {MarkdownEditorConfig} config - * @returns {Promise} - */ -export async function init(config) { - /** - * @type {MarkdownEditor} - */ - const editor = { - config, - markdown: new Markdown(), - settings: new Settings(config.settingInputs), - }; - - editor.actions = new Actions(editor); - editor.display = new Display(editor); - editor.cm = await initCodemirror(editor); - - listen(editor); - - return editor; -} - -/** - * @typedef MarkdownEditorConfig - * @property {String} pageId - * @property {Element} container - * @property {Element} displayEl - * @property {HTMLTextAreaElement} inputEl - * @property {String} drawioUrl - * @property {HTMLInputElement[]} settingInputs - * @property {Object} text - */ - -/** - * @typedef MarkdownEditor - * @property {MarkdownEditorConfig} config - * @property {Display} display - * @property {Markdown} markdown - * @property {Actions} actions - * @property {EditorView} cm - * @property {Settings} settings - */ diff --git a/resources/js/markdown/index.mts b/resources/js/markdown/index.mts new file mode 100644 index 000000000..0a6e974b7 --- /dev/null +++ b/resources/js/markdown/index.mts @@ -0,0 +1,71 @@ +import {Markdown} from './markdown'; +import {Display} from './display'; +import {Actions} from './actions'; +import {Settings} from './settings'; +import {listenToCommonEvents} from './common-events'; +import {init as initCodemirror} from './codemirror'; +import {MarkdownEditorInput} from "./inputs/interface"; +import {CodemirrorInput} from "./inputs/codemirror"; +import {TextareaInput} from "./inputs/textarea"; +import {provideShortcutMap} from "./shortcuts"; +import {getMarkdownDomEventHandlers} from "./dom-handlers"; + +export interface MarkdownEditorConfig { + pageId: string; + container: Element; + displayEl: HTMLIFrameElement; + inputEl: HTMLTextAreaElement; + drawioUrl: string; + settingInputs: HTMLInputElement[]; + text: Record; +} + +export interface MarkdownEditor { + config: MarkdownEditorConfig; + display: Display; + markdown: Markdown; + actions: Actions; + input: MarkdownEditorInput; + settings: Settings; +} + +/** + * Initiate a new Markdown editor instance. + */ +export async function init(config: MarkdownEditorConfig): Promise { + const editor: MarkdownEditor = { + config, + markdown: new Markdown(), + settings: new Settings(config.settingInputs), + } as MarkdownEditor; + + editor.actions = new Actions(editor); + editor.display = new Display(editor); + + const eventHandlers = getMarkdownDomEventHandlers(editor); + const shortcuts = provideShortcutMap(editor); + const onInputChange = () => editor.actions.updateAndRender(); + + const initCodemirrorInput: () => Promise = async () => { + const codeMirror = await initCodemirror(config.inputEl, shortcuts, eventHandlers, onInputChange); + return new CodemirrorInput(codeMirror); + }; + const initTextAreaInput: () => Promise = async () => { + return new TextareaInput(config.inputEl, shortcuts, eventHandlers, onInputChange); + }; + + const isPlainEditor = Boolean(editor.settings.get('plainEditor')); + editor.input = await (isPlainEditor ? initTextAreaInput() : initCodemirrorInput()); + editor.settings.onChange('plainEditor', async (value) => { + const isPlain = Boolean(value); + const newInput = await (isPlain ? initTextAreaInput() : initCodemirrorInput()); + editor.input.teardown(); + editor.input = newInput; + }); + + listenToCommonEvents(editor); + + return editor; +} + + diff --git a/resources/js/markdown/inputs/codemirror.ts b/resources/js/markdown/inputs/codemirror.ts new file mode 100644 index 000000000..827068238 --- /dev/null +++ b/resources/js/markdown/inputs/codemirror.ts @@ -0,0 +1,128 @@ +import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface"; +import {EditorView} from "@codemirror/view"; +import {ChangeSpec, TransactionSpec} from "@codemirror/state"; + + +export class CodemirrorInput implements MarkdownEditorInput { + protected cm: EditorView; + + constructor(cm: EditorView) { + this.cm = cm; + } + + teardown(): void { + this.cm.destroy(); + } + + focus(): void { + if (!this.cm.hasFocus) { + this.cm.focus(); + } + } + + getSelection(): MarkdownEditorInputSelection { + return this.cm.state.selection.main; + } + + getSelectionText(selection?: MarkdownEditorInputSelection): string { + selection = selection || this.getSelection(); + return this.cm.state.sliceDoc(selection.from, selection.to); + } + + setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean = false) { + this.cm.dispatch({ + selection: {anchor: selection.from, head: selection.to}, + scrollIntoView, + }); + } + + getText(): string { + return this.cm.state.doc.toString(); + } + + getTextAboveView(): string { + const blockInfo = this.cm.lineBlockAtHeight(this.cm.scrollDOM.scrollTop); + return this.cm.state.sliceDoc(0, blockInfo.from); + } + + setText(text: string, selection?: MarkdownEditorInputSelection) { + selection = selection || this.getSelection(); + const newDoc = this.cm.state.toText(text); + const newSelectFrom = Math.min(selection.from, newDoc.length); + const scrollTop = this.cm.scrollDOM.scrollTop; + this.dispatchChange(0, this.cm.state.doc.length, text, newSelectFrom); + this.focus(); + window.requestAnimationFrame(() => { + this.cm.scrollDOM.scrollTop = scrollTop; + }); + } + + spliceText(from: number, to: number, newText: string, selection: Partial | null = null) { + const end = (selection?.from === selection?.to) ? null : selection?.to; + this.dispatchChange(from, to, newText, selection?.from, end) + } + + appendText(text: string) { + const end = this.cm.state.doc.length; + this.dispatchChange(end, end, `\n${text}`); + } + + getLineText(lineIndex: number = -1): string { + const index = lineIndex > -1 ? lineIndex : this.getSelection().from; + return this.cm.state.doc.lineAt(index).text; + } + + eventToPosition(event: MouseEvent): MarkdownEditorInputSelection { + const cursorPos = this.cm.posAtCoords({x: event.screenX, y: event.screenY}, false); + return {from: cursorPos, to: cursorPos}; + } + + getLineRangeFromPosition(position: number): MarkdownEditorInputSelection { + const line = this.cm.state.doc.lineAt(position); + return {from: line.from, to: line.to}; + } + + searchForLineContaining(text: string): MarkdownEditorInputSelection | null { + const docText = this.cm.state.doc; + let lineCount = 1; + let scrollToLine = -1; + for (const line of docText.iterLines()) { + if (line.includes(text)) { + scrollToLine = lineCount; + break; + } + lineCount += 1; + } + + if (scrollToLine === -1) { + return null; + } + + const line = docText.line(scrollToLine); + return {from: line.from, to: line.to}; + } + + /** + * Dispatch changes to the editor. + */ + protected dispatchChange(from: number, to: number|null = null, text: string|null = null, selectFrom: number|null = null, selectTo: number|null = null): void { + const change: ChangeSpec = {from}; + if (to) { + change.to = to; + } + if (text) { + change.insert = text; + } + const tr: TransactionSpec = {changes: change}; + + if (selectFrom) { + tr.selection = {anchor: selectFrom}; + if (selectTo) { + tr.selection.head = selectTo; + } + } + + this.cm.dispatch(tr); + } + +} \ No newline at end of file diff --git a/resources/js/markdown/inputs/interface.ts b/resources/js/markdown/inputs/interface.ts new file mode 100644 index 000000000..1f7474a50 --- /dev/null +++ b/resources/js/markdown/inputs/interface.ts @@ -0,0 +1,81 @@ + +export interface MarkdownEditorInputSelection { + from: number; + to: number; +} + +export interface MarkdownEditorInput { + /** + * Focus on the editor. + */ + focus(): void; + + /** + * Get the current selection range. + */ + getSelection(): MarkdownEditorInputSelection; + + /** + * Get the text of the given (or current) selection range. + */ + getSelectionText(selection?: MarkdownEditorInputSelection): string; + + /** + * Set the selection range of the editor. + */ + setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void; + + /** + * Get the full text of the input. + */ + getText(): string; + + /** + * Get just the text which is above (out) the current view range. + * This is used for position estimation. + */ + getTextAboveView(): string; + + /** + * Set the full text of the input. + * Optionally can provide a selection to restore after setting text. + */ + setText(text: string, selection?: MarkdownEditorInputSelection): void; + + /** + * Splice in/out text within the input. + * Optionally can provide a selection to restore after setting text. + */ + spliceText(from: number, to: number, newText: string, selection: Partial|null): void; + + /** + * Append text to the end of the editor. + */ + appendText(text: string): void; + + /** + * Get the text of the given line number otherwise the text + * of the current selected line. + */ + getLineText(lineIndex:number): string; + + /** + * Get a selection representing the line range from the given position. + */ + getLineRangeFromPosition(position: number): MarkdownEditorInputSelection; + + /** + * Convert the given event position to a selection position within the input. + */ + eventToPosition(event: MouseEvent): MarkdownEditorInputSelection; + + /** + * Search and return a line range which includes the provided text. + */ + searchForLineContaining(text: string): MarkdownEditorInputSelection|null; + + /** + * Tear down the input. + */ + teardown(): void; +} \ No newline at end of file diff --git a/resources/js/markdown/inputs/textarea.ts b/resources/js/markdown/inputs/textarea.ts new file mode 100644 index 000000000..e0c3ac37c --- /dev/null +++ b/resources/js/markdown/inputs/textarea.ts @@ -0,0 +1,315 @@ +import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface"; +import {MarkdownEditorShortcutMap} from "../shortcuts"; +import {MarkdownEditorEventMap} from "../dom-handlers"; +import {debounce} from "../../services/util"; + +type UndoStackEntry = { + content: string; + selection: MarkdownEditorInputSelection; +} + +class UndoStack { + protected onChangeDebounced: (callback: () => UndoStackEntry) => void; + + protected stack: UndoStackEntry[] = []; + protected pointer: number = -1; + protected lastActionTime: number = 0; + + constructor() { + this.onChangeDebounced = debounce(this.onChange, 1000, false); + } + + undo(): UndoStackEntry|null { + if (this.pointer < 1) { + return null; + } + + this.lastActionTime = Date.now(); + this.pointer -= 1; + return this.stack[this.pointer]; + } + + redo(): UndoStackEntry|null { + const atEnd = this.pointer === this.stack.length - 1; + if (atEnd) { + return null; + } + + this.lastActionTime = Date.now(); + this.pointer++; + return this.stack[this.pointer]; + } + + push(getValueCallback: () => UndoStackEntry): void { + // Ignore changes made via undo/redo actions + if (Date.now() - this.lastActionTime < 100) { + return; + } + + this.onChangeDebounced(getValueCallback); + } + + protected onChange(getValueCallback: () => UndoStackEntry) { + // Trim the end of the stack from the pointer since we're branching away + if (this.pointer !== this.stack.length - 1) { + this.stack = this.stack.slice(0, this.pointer) + } + + this.stack.push(getValueCallback()); + + // Limit stack size + if (this.stack.length > 50) { + this.stack = this.stack.slice(this.stack.length - 50); + } + + this.pointer = this.stack.length - 1; + } +} + +export class TextareaInput implements MarkdownEditorInput { + + protected input: HTMLTextAreaElement; + protected shortcuts: MarkdownEditorShortcutMap; + protected events: MarkdownEditorEventMap; + protected onChange: () => void; + protected eventController = new AbortController(); + protected undoStack = new UndoStack(); + + protected textSizeCache: {x: number; y: number}|null = null; + + constructor( + input: HTMLTextAreaElement, + shortcuts: MarkdownEditorShortcutMap, + events: MarkdownEditorEventMap, + onChange: () => void + ) { + this.input = input; + this.shortcuts = shortcuts; + this.events = events; + this.onChange = onChange; + + this.onKeyDown = this.onKeyDown.bind(this); + this.configureLocalShortcuts(); + this.configureListeners(); + + this.input.style.removeProperty("display"); + this.undoStack.push(() => ({content: this.getText(), selection: this.getSelection()})); + } + + teardown() { + this.eventController.abort('teardown'); + } + + configureLocalShortcuts(): void { + this.shortcuts['Mod-z'] = () => { + const undoEntry = this.undoStack.undo(); + if (undoEntry) { + this.setText(undoEntry.content); + this.setSelection(undoEntry.selection, false); + } + }; + this.shortcuts['Mod-y'] = () => { + const redoContent = this.undoStack.redo(); + if (redoContent) { + this.setText(redoContent.content); + this.setSelection(redoContent.selection, false); + } + } + } + + configureListeners(): void { + // Keyboard shortcuts + this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal}); + + // Shared event listeners + for (const [name, listener] of Object.entries(this.events)) { + this.input.addEventListener(name, listener, {signal: this.eventController.signal}); + } + + // Input change handling + this.input.addEventListener('input', () => { + this.onChange(); + this.undoStack.push(() => ({content: this.input.value, selection: this.getSelection()})); + }, {signal: this.eventController.signal}); + } + + onKeyDown(e: KeyboardEvent) { + const isApple = navigator.platform.startsWith("Mac") || navigator.platform === "iPhone"; + const key = e.key.length > 1 ? e.key : e.key.toLowerCase(); + const keyParts = [ + e.shiftKey ? 'Shift' : null, + isApple && e.metaKey ? 'Mod' : null, + !isApple && e.ctrlKey ? 'Mod' : null, + key, + ]; + + const keyString = keyParts.filter(Boolean).join('-'); + if (this.shortcuts[keyString]) { + e.preventDefault(); + this.shortcuts[keyString](); + } + } + + appendText(text: string): void { + this.input.value += `\n${text}`; + this.input.dispatchEvent(new Event('input')); + } + + eventToPosition(event: MouseEvent): MarkdownEditorInputSelection { + const eventCoords = this.mouseEventToTextRelativeCoords(event); + return this.inputPositionToSelection(eventCoords.x, eventCoords.y); + } + + focus(): void { + this.input.focus(); + } + + getLineRangeFromPosition(position: number): MarkdownEditorInputSelection { + const lines = this.getText().split('\n'); + let lineStart = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineEnd = lineStart + line.length; + if (position <= lineEnd) { + return {from: lineStart, to: lineEnd}; + } + lineStart = lineEnd + 1; + } + + return {from: 0, to: 0}; + } + + getLineText(lineIndex: number): string { + const text = this.getText(); + const lines = text.split("\n"); + return lines[lineIndex] || ''; + } + + getSelection(): MarkdownEditorInputSelection { + return {from: this.input.selectionStart, to: this.input.selectionEnd}; + } + + getSelectionText(selection?: MarkdownEditorInputSelection): string { + const text = this.getText(); + const range = selection || this.getSelection(); + return text.slice(range.from, range.to); + } + + getText(): string { + return this.input.value; + } + + getTextAboveView(): string { + const scrollTop = this.input.scrollTop; + const selection = this.inputPositionToSelection(0, scrollTop); + return this.getSelectionText({from: 0, to: selection.to}); + } + + searchForLineContaining(text: string): MarkdownEditorInputSelection | null { + const textPosition = this.getText().indexOf(text); + if (textPosition > -1) { + return this.getLineRangeFromPosition(textPosition); + } + + return null; + } + + setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void { + this.input.selectionStart = selection.from; + this.input.selectionEnd = selection.to; + } + + setText(text: string, selection?: MarkdownEditorInputSelection): void { + this.input.value = text; + this.input.dispatchEvent(new Event('input')); + if (selection) { + this.setSelection(selection, false); + } + } + + spliceText(from: number, to: number, newText: string, selection: Partial | null): void { + const text = this.getText(); + const updatedText = text.slice(0, from) + newText + text.slice(to); + this.setText(updatedText); + if (selection && selection.from) { + const newSelection = {from: selection.from, to: selection.to || selection.from}; + this.setSelection(newSelection, false); + } + } + + protected measureTextSize(): {x: number; y: number} { + if (this.textSizeCache) { + return this.textSizeCache; + } + + const el = document.createElement("div"); + el.textContent = `a\nb`; + const inputStyles = window.getComputedStyle(this.input) + el.style.font = inputStyles.font; + el.style.lineHeight = inputStyles.lineHeight; + el.style.padding = '0px'; + el.style.display = 'inline-block'; + el.style.visibility = 'hidden'; + el.style.position = 'absolute'; + el.style.whiteSpace = 'pre'; + this.input.after(el); + + const bounds = el.getBoundingClientRect(); + el.remove(); + this.textSizeCache = { + x: bounds.width, + y: bounds.height / 2, + }; + return this.textSizeCache; + } + + protected measureLineCharCount(textWidth: number): number { + const inputStyles = window.getComputedStyle(this.input); + const paddingLeft = Number(inputStyles.paddingLeft.replace('px', '')); + const paddingRight = Number(inputStyles.paddingRight.replace('px', '')); + const width = Number(inputStyles.width.replace('px', '')); + const textSpace = width - (paddingLeft + paddingRight); + + return Math.floor(textSpace / textWidth); + } + + protected mouseEventToTextRelativeCoords(event: MouseEvent): {x: number; y: number} { + const inputBounds = this.input.getBoundingClientRect(); + const inputStyles = window.getComputedStyle(this.input); + const paddingTop = Number(inputStyles.paddingTop.replace('px', '')); + const paddingLeft = Number(inputStyles.paddingLeft.replace('px', '')); + + const xPos = Math.max(event.clientX - (inputBounds.left + paddingLeft), 0); + const yPos = Math.max((event.clientY - (inputBounds.top + paddingTop)) + this.input.scrollTop, 0); + + return {x: xPos, y: yPos}; + } + + protected inputPositionToSelection(x: number, y: number): MarkdownEditorInputSelection { + const textSize = this.measureTextSize(); + const lineWidth = this.measureLineCharCount(textSize.x); + + const lines = this.getText().split('\n'); + + let currY = 0; + let currPos = 0; + for (const line of lines) { + let linePos = 0; + const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1); + for (let i = 0; i < wrapCount; i++) { + currY += textSize.y; + if (currY > y) { + const targetX = Math.floor(x / textSize.x); + const maxPos = Math.min(currPos + linePos + targetX, currPos + line.length); + return {from: maxPos, to: maxPos}; + } + + linePos += lineWidth; + } + + currPos += line.length + 1; + } + + return this.getSelection(); + } +} \ No newline at end of file diff --git a/resources/js/markdown/markdown.js b/resources/js/markdown/markdown.ts similarity index 68% rename from resources/js/markdown/markdown.js rename to resources/js/markdown/markdown.ts index e63184acc..07ea09e91 100644 --- a/resources/js/markdown/markdown.js +++ b/resources/js/markdown/markdown.ts @@ -1,7 +1,9 @@ import MarkdownIt from 'markdown-it'; +// @ts-ignore import mdTasksLists from 'markdown-it-task-lists'; export class Markdown { + protected renderer: MarkdownIt; constructor() { this.renderer = new MarkdownIt({html: true}); @@ -9,19 +11,16 @@ export class Markdown { } /** - * Get the front-end render used to convert markdown to HTML. - * @returns {MarkdownIt} + * Get the front-end render used to convert Markdown to HTML. */ - getRenderer() { + getRenderer(): MarkdownIt { return this.renderer; } /** * Convert the given Markdown to HTML. - * @param {String} markdown - * @returns {String} */ - render(markdown) { + render(markdown: string): string { return this.renderer.render(markdown); } diff --git a/resources/js/markdown/settings.js b/resources/js/markdown/settings.js deleted file mode 100644 index b843aaa8a..000000000 --- a/resources/js/markdown/settings.js +++ /dev/null @@ -1,63 +0,0 @@ -export class Settings { - - constructor(settingInputs) { - this.settingMap = { - scrollSync: true, - showPreview: true, - editorWidth: 50, - }; - this.changeListeners = {}; - this.loadFromLocalStorage(); - this.applyToInputs(settingInputs); - this.listenToInputChanges(settingInputs); - } - - applyToInputs(inputs) { - for (const input of inputs) { - const name = input.getAttribute('name').replace('md-', ''); - input.checked = this.settingMap[name]; - } - } - - listenToInputChanges(inputs) { - for (const input of inputs) { - input.addEventListener('change', () => { - const name = input.getAttribute('name').replace('md-', ''); - this.set(name, input.checked); - }); - } - } - - loadFromLocalStorage() { - const lsValString = window.localStorage.getItem('md-editor-settings'); - if (!lsValString) { - return; - } - - const lsVals = JSON.parse(lsValString); - for (const [key, value] of Object.entries(lsVals)) { - if (value !== null && this.settingMap[key] !== undefined) { - this.settingMap[key] = value; - } - } - } - - set(key, value) { - this.settingMap[key] = value; - window.localStorage.setItem('md-editor-settings', JSON.stringify(this.settingMap)); - for (const listener of (this.changeListeners[key] || [])) { - listener(value); - } - } - - get(key) { - return this.settingMap[key] || null; - } - - onChange(key, callback) { - const listeners = this.changeListeners[key] || []; - listeners.push(callback); - this.changeListeners[key] = listeners; - } - -} diff --git a/resources/js/markdown/settings.ts b/resources/js/markdown/settings.ts new file mode 100644 index 000000000..c446cbe05 --- /dev/null +++ b/resources/js/markdown/settings.ts @@ -0,0 +1,82 @@ +type ChangeListener = (value: boolean|number) => void; + +export class Settings { + protected changeListeners: Record = {}; + + protected settingMap: Record = { + scrollSync: true, + showPreview: true, + editorWidth: 50, + plainEditor: false, + }; + + constructor(settingInputs: HTMLInputElement[]) { + this.loadFromLocalStorage(); + this.applyToInputs(settingInputs); + this.listenToInputChanges(settingInputs); + } + + protected applyToInputs(inputs: HTMLInputElement[]): void { + for (const input of inputs) { + const name = input.getAttribute('name')?.replace('md-', ''); + if (name && name in this.settingMap) { + const value = this.settingMap[name]; + if (typeof value === 'boolean') { + input.checked = value; + } else { + input.value = value.toString(); + } + } + } + } + + protected listenToInputChanges(inputs: HTMLInputElement[]): void { + for (const input of inputs) { + input.addEventListener('change', () => { + const name = input.getAttribute('name')?.replace('md-', ''); + if (name && name in this.settingMap) { + let value = (input.type === 'checkbox') ? input.checked : Number(input.value); + this.set(name, value); + } + }); + } + } + + protected loadFromLocalStorage(): void { + const lsValString = window.localStorage.getItem('md-editor-settings'); + if (!lsValString) { + return; + } + + try { + const lsVals = JSON.parse(lsValString); + for (const [key, value] of Object.entries(lsVals)) { + if (value !== null && value !== undefined && key in this.settingMap) { + this.settingMap[key] = value as boolean|number; + } + } + } catch (error) { + console.warn('Failed to parse settings from localStorage:', error); + } + } + + public set(key: string, value: boolean|number): void { + this.settingMap[key] = value; + window.localStorage.setItem('md-editor-settings', JSON.stringify(this.settingMap)); + + const listeners = this.changeListeners[key] || []; + for (const listener of listeners) { + listener(value); + } + } + + public get(key: string): number|boolean|null { + return this.settingMap[key] ?? null; + } + + public onChange(key: string, callback: ChangeListener): void { + const listeners = this.changeListeners[key] || []; + listeners.push(callback); + this.changeListeners[key] = listeners; + } +} \ No newline at end of file diff --git a/resources/js/markdown/shortcuts.js b/resources/js/markdown/shortcuts.ts similarity index 69% rename from resources/js/markdown/shortcuts.js rename to resources/js/markdown/shortcuts.ts index 543e6dcdd..175e8f4f0 100644 --- a/resources/js/markdown/shortcuts.js +++ b/resources/js/markdown/shortcuts.ts @@ -1,10 +1,12 @@ +import {MarkdownEditor} from "./index.mjs"; + +export type MarkdownEditorShortcutMap = Record void>; + /** * Provide shortcuts for the editor instance. - * @param {MarkdownEditor} editor - * @returns {Object} */ -function provide(editor) { - const shortcuts = {}; +export function provideShortcutMap(editor: MarkdownEditor): MarkdownEditorShortcutMap { + const shortcuts: MarkdownEditorShortcutMap = {}; // Insert Image shortcut shortcuts['Shift-Mod-i'] = () => editor.actions.insertImage(); @@ -39,24 +41,3 @@ function provide(editor) { return shortcuts; } - -/** - * Get the editor shortcuts in CodeMirror keybinding format. - * @param {MarkdownEditor} editor - * @return {{key: String, run: function, preventDefault: boolean}[]} - */ -export function provideKeyBindings(editor) { - const shortcuts = provide(editor); - const keyBindings = []; - - const wrapAction = action => () => { - action(); - return true; - }; - - for (const [shortcut, action] of Object.entries(shortcuts)) { - keyBindings.push({key: shortcut, run: wrapAction(action), preventDefault: true}); - } - - return keyBindings; -} diff --git a/resources/js/services/dom.ts b/resources/js/services/dom.ts index c3817536c..8696fe816 100644 --- a/resources/js/services/dom.ts +++ b/resources/js/services/dom.ts @@ -256,4 +256,22 @@ export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number) export function hashElement(element: HTMLElement): string { const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, ''); return cyrb53(normalisedElemText); +} + +/** + * Find the closest scroll container parent for the given element + * otherwise will default to the body element. + */ +export function findClosestScrollContainer(start: HTMLElement): HTMLElement { + let el: HTMLElement|null = start; + do { + const computed = window.getComputedStyle(el); + if (computed.overflowY === 'scroll') { + return el; + } + + el = el.parentElement; + } while (el); + + return document.body; } \ No newline at end of file diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts index 61a02a3d2..74ae1a935 100644 --- a/resources/js/services/util.ts +++ b/resources/js/services/util.ts @@ -5,7 +5,7 @@ * leading edge, instead of the trailing. * @attribution https://davidwalsh.name/javascript-debounce-function */ -export function debounce(func: Function, waitMs: number, immediate: boolean): Function { +export function debounce any>(func: T, waitMs: number, immediate: boolean): T { let timeout: number|null = null; return function debouncedWrapper(this: any, ...args: any[]) { const context: any = this; @@ -19,7 +19,7 @@ export function debounce(func: Function, waitMs: number, immediate: boolean): Fu } timeout = window.setTimeout(later, waitMs); if (callNow) func.apply(context, args); - }; + } as T; } function isDetailsElement(element: HTMLElement): element is HTMLDetailsElement { diff --git a/resources/js/wysiwyg-tinymce/config.js b/resources/js/wysiwyg-tinymce/config.js index 1666aa500..c0cfd37d9 100644 --- a/resources/js/wysiwyg-tinymce/config.js +++ b/resources/js/wysiwyg-tinymce/config.js @@ -310,54 +310,6 @@ export function buildForEditor(options) { }; } -/** - * @param {WysiwygConfigOptions} options - * @return {RawEditorOptions} - */ -export function buildForInput(options) { - // Set language - window.tinymce.addI18n(options.language, options.translationMap); - - // BookStack Version - const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1]; - - // Return config object - return { - width: '100%', - height: '185px', - target: options.containerElement, - cache_suffix: `?version=${version}`, - content_css: [ - window.baseUrl('/dist/styles.css'), - ], - branding: false, - skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5', - body_class: 'wysiwyg-input', - browser_spellcheck: true, - relative_urls: false, - language: options.language, - directionality: options.textDirection, - remove_script_host: false, - document_base_url: window.baseUrl('/'), - end_container_on_empty_block: true, - remove_trailing_brs: false, - statusbar: false, - menubar: false, - plugins: 'link autolink lists', - contextmenu: false, - toolbar: 'bold italic link bullist numlist', - content_style: getContentStyle(options), - file_picker_types: 'file', - valid_elements: 'p,a[href|title|target],ol,ul,li,strong,em,br', - file_picker_callback: filePickerCallback, - init_instance_callback(editor) { - addCustomHeadContent(editor.getDoc()); - - editor.contentDocument.documentElement.classList.toggle('dark-mode', options.darkMode); - }, - }; -} - /** * @typedef {Object} WysiwygConfigOptions * @property {Element} containerElement diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 7ecf91d23..e01b4e8f4 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -1,75 +1,78 @@ -import {$getSelection, createEditor, CreateEditorArgs, LexicalEditor} from 'lexical'; +import {createEditor, LexicalEditor} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; -import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes'; +import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes'; import {buildEditorUI} from "./ui"; -import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions"; +import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; import {EditorUiContext} from "./ui/framework/core"; import {listen as listenToCommonEvents} from "./services/common-events"; import {registerDropPasteHandling} from "./services/drop-paste-handling"; import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler"; import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; -import {el} from "./utils/dom"; import {registerShortcuts} from "./services/shortcuts"; import {registerNodeResizer} from "./ui/framework/helpers/node-resizer"; import {registerKeyboardHandling} from "./services/keyboard-handling"; import {registerAutoLinks} from "./services/auto-links"; +import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars"; +import {modals} from "./ui/defaults/modals"; +import {CodeBlockDecorator} from "./ui/decorators/code-block"; +import {DiagramDecorator} from "./ui/decorators/diagram"; +import {registerMouseHandling} from "./services/mouse-handling"; + +const theme = { + text: { + bold: 'editor-theme-bold', + code: 'editor-theme-code', + italic: 'editor-theme-italic', + strikethrough: 'editor-theme-strikethrough', + subscript: 'editor-theme-subscript', + superscript: 'editor-theme-superscript', + underline: 'editor-theme-underline', + underlineStrikethrough: 'editor-theme-underline-strikethrough', + } +}; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { - const config: CreateEditorArgs = { + const editor = createEditor({ namespace: 'BookStackPageEditor', nodes: getNodesForPageEditor(), onError: console.error, - theme: { - text: { - bold: 'editor-theme-bold', - code: 'editor-theme-code', - italic: 'editor-theme-italic', - strikethrough: 'editor-theme-strikethrough', - subscript: 'editor-theme-subscript', - superscript: 'editor-theme-superscript', - underline: 'editor-theme-underline', - underlineStrikethrough: 'editor-theme-underline-strikethrough', - } - } - }; - - const editArea = el('div', { - contenteditable: 'true', - class: 'editor-content-area page-content', + theme: theme, }); - const editWrap = el('div', { - class: 'editor-content-wrap', - }, [editArea]); - - container.append(editWrap); - container.classList.add('editor-container'); - container.setAttribute('dir', options.textDirection); - if (options.darkMode) { - container.classList.add('editor-dark'); - } - - const editor = createEditor(config); - editor.setRootElement(editArea); - const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options); + const context: EditorUiContext = buildEditorUI(container, editor, { + ...options, + editorClass: 'page-content', + }); + editor.setRootElement(context.editorDOM); mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), registerShortcuts(context), registerKeyboardHandling(context), - registerTableResizer(editor, editWrap), + registerMouseHandling(context), + registerTableResizer(editor, context.scrollDOM), registerTableSelectionHandler(editor), - registerTaskListHandler(editor, editArea), + registerTaskListHandler(editor, context.editorDOM), registerDropPasteHandling(context), registerNodeResizer(context), registerAutoLinks(editor), ); - listenToCommonEvents(editor); + // Register toolbars, modals & decorators + context.manager.setToolbar(getMainEditorFullToolbar(context)); + for (const key of Object.keys(contextToolbars)) { + context.manager.registerContextToolbar(key, contextToolbars[key]); + } + for (const key of Object.keys(modals)) { + context.manager.registerModal(key, modals[key]); + } + context.manager.registerDecoratorType('code', CodeBlockDecorator); + context.manager.registerDecoratorType('diagram', DiagramDecorator); + listenToCommonEvents(editor); setEditorContentFromHtml(editor, htmlContent); const debugView = document.getElementById('lexical-debug'); @@ -89,17 +92,76 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerCommonNodeMutationListeners(context); - return new SimpleWysiwygEditorInterface(editor); + return new SimpleWysiwygEditorInterface(context); +} + +export function createBasicEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { + const editor = createEditor({ + namespace: 'BookStackBasicEditor', + nodes: getNodesForBasicEditor(), + onError: console.error, + theme: theme, + }); + const context: EditorUiContext = buildEditorUI(container, editor, options); + editor.setRootElement(context.editorDOM); + + const editorTeardown = mergeRegister( + registerRichText(editor), + registerHistory(editor, createEmptyHistoryState(), 300), + registerShortcuts(context), + registerAutoLinks(editor), + ); + + // Register toolbars, modals & decorators + context.manager.setToolbar(getBasicEditorToolbar(context)); + context.manager.registerContextToolbar('link', contextToolbars.link); + context.manager.registerModal('link', modals.link); + context.manager.onTeardown(editorTeardown); + + setEditorContentFromHtml(editor, htmlContent); + + return new SimpleWysiwygEditorInterface(context); } export class SimpleWysiwygEditorInterface { - protected editor: LexicalEditor; + protected context: EditorUiContext; + protected onChangeListeners: (() => void)[] = []; + protected editorListenerTeardown: (() => void)|null = null; - constructor(editor: LexicalEditor) { - this.editor = editor; + constructor(context: EditorUiContext) { + this.context = context; } async getContentAsHtml(): Promise { - return await getEditorContentAsHtml(this.editor); + return await getEditorContentAsHtml(this.context.editor); + } + + onChange(listener: () => void) { + this.onChangeListeners.push(listener); + this.startListeningToChanges(); + } + + focus(): void { + focusEditor(this.context.editor); + } + + remove() { + this.context.manager.teardown(); + this.context.containerDOM.remove(); + if (this.editorListenerTeardown) { + this.editorListenerTeardown(); + } + } + + protected startListeningToChanges(): void { + if (this.editorListenerTeardown) { + return; + } + + this.editorListenerTeardown = this.context.editor.registerUpdateListener(() => { + for (const listener of this.onChangeListeners) { + listener(); + } + }); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index 7306e6bca..6d79c01cc 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -175,7 +175,7 @@ export type NodeKey = string; export class LexicalNode { // Allow us to look up the type including static props - ['constructor']!: KlassConstructor; + declare ['constructor']: KlassConstructor; /** @internal */ __type: string; /** @internal */ diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index e18ef9756..fd87877ee 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -848,4 +848,20 @@ export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: dispatchKeydownEventForNode(node, editor, key); } }); +} + +export function dispatchEditorMouseClick(editor: LexicalEditor, clientX: number, clientY: number) { + const dom = editor.getRootElement(); + if (!dom) { + return; + } + + const event = new MouseEvent('click', { + clientX: clientX, + clientY: clientY, + bubbles: true, + cancelable: true, + }); + dom?.dispatchEvent(event); + editor.commitUpdates(); } \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts index 99d2669d9..5015f593e 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts @@ -24,7 +24,7 @@ export interface DecoratorNode { /** @noInheritDoc */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class DecoratorNode extends LexicalNode { - ['constructor']!: KlassConstructor>; + declare ['constructor']: KlassConstructor>; constructor(key?: NodeKey) { super(key); } diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts index 9ad508411..a27603773 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -55,7 +55,7 @@ export interface ElementNode { /** @noInheritDoc */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class ElementNode extends LexicalNode { - ['constructor']!: KlassConstructor; + declare ['constructor']: KlassConstructor; /** @internal */ __first: null | NodeKey; /** @internal */ diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts index 2d28db08c..b1746e7f8 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts @@ -22,7 +22,7 @@ export type SerializedLineBreakNode = SerializedLexicalNode; /** @noInheritDoc */ export class LineBreakNode extends LexicalNode { - ['constructor']!: KlassConstructor; + declare ['constructor']: KlassConstructor; static getType(): string { return 'linebreak'; } diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts index e8d044b21..6711936da 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts @@ -44,7 +44,7 @@ export type SerializedParagraphNode = Spread< /** @noInheritDoc */ export class ParagraphNode extends CommonBlockNode { - ['constructor']!: KlassConstructor; + declare ['constructor']: KlassConstructor; /** @internal */ __textFormat: number; __textStyle: string; diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts index 9a4867494..35cc073a0 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts @@ -284,7 +284,7 @@ export interface TextNode { /** @noInheritDoc */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class TextNode extends LexicalNode { - ['constructor']!: KlassConstructor; + declare ['constructor']: KlassConstructor; __text: string; /** @internal */ __format: number; diff --git a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts index e5064121a..b466ee34a 100644 --- a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts +++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts @@ -146,7 +146,7 @@ describe('HTML', () => { }); expect(html).toBe( - '

Hello

World

', + '

Hello

\n

World

', ); }); diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts index 5018e10b4..de5e53bb8 100644 --- a/resources/js/wysiwyg/lexical/html/index.ts +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -85,7 +85,18 @@ export function $generateHtmlFromNodes( $appendNodesToHTML(editor, topLevelNode, container, selection); } - return container.innerHTML; + const nodeCode = []; + for (const node of container.childNodes) { + if ("outerHTML" in node) { + nodeCode.push(node.outerHTML) + } else { + const wrap = document.createElement('div'); + wrap.appendChild(node.cloneNode(true)); + nodeCode.push(wrap.innerHTML); + } + } + + return nodeCode.join('\n'); } function $appendNodesToHTML( diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts index 0f3513682..1103f73d3 100644 --- a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts @@ -273,18 +273,6 @@ describe('LexicalAutoAutoLinkNode tests', () => { }); }); - test('AutoLinkNode.createDOM() sanitizes javascript: URLs', async () => { - const {editor} = testEnv; - - await editor.update(() => { - // eslint-disable-next-line no-script-url - const autoLinkNode = new AutoLinkNode('javascript:alert(0)'); - expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( - '', - ); - }); - }); - test('AutoLinkNode.updateDOM()', async () => { const {editor} = testEnv; diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts index 1aff91863..c50450302 100644 --- a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts @@ -218,18 +218,6 @@ describe('LexicalLinkNode tests', () => { }); }); - test('LinkNode.createDOM() sanitizes javascript: URLs', async () => { - const {editor} = testEnv; - - await editor.update(() => { - // eslint-disable-next-line no-script-url - const linkNode = new LinkNode('javascript:alert(0)'); - expect(linkNode.createDOM(editorConfig).outerHTML).toBe( - '', - ); - }); - }); - test('LinkNode.updateDOM()', async () => { const {editor} = testEnv; diff --git a/resources/js/wysiwyg/lexical/link/index.ts b/resources/js/wysiwyg/lexical/link/index.ts index 884fe9153..336bb1546 100644 --- a/resources/js/wysiwyg/lexical/link/index.ts +++ b/resources/js/wysiwyg/lexical/link/index.ts @@ -48,14 +48,6 @@ export type SerializedLinkNode = Spread< type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement; -const SUPPORTED_URL_PROTOCOLS = new Set([ - 'http:', - 'https:', - 'mailto:', - 'sms:', - 'tel:', -]); - /** @noInheritDoc */ export class LinkNode extends ElementNode { /** @internal */ @@ -90,7 +82,7 @@ export class LinkNode extends ElementNode { createDOM(config: EditorConfig): LinkHTMLElementType { const element = document.createElement('a'); - element.href = this.sanitizeUrl(this.__url); + element.href = this.__url; if (this.__target !== null) { element.target = this.__target; } @@ -166,19 +158,6 @@ export class LinkNode extends ElementNode { return node; } - sanitizeUrl(url: string): string { - try { - const parsedUrl = new URL(url); - // eslint-disable-next-line no-script-url - if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) { - return 'about:blank'; - } - } catch { - return url; - } - return url; - } - exportJSON(): SerializedLinkNode | SerializedAutoLinkNode { return { ...super.exportJSON(), diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts index 1fc6b42bb..1c9d7ecf6 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts @@ -353,10 +353,17 @@ export function $convertTableCellNodeElement( const hasUnderlineTextDecoration = textDecoration.includes('underline'); if (domNode instanceof HTMLElement) { - tableCellNode.setStyles(extractStyleMapFromElement(domNode)); + const styleMap = extractStyleMapFromElement(domNode); + styleMap.delete('background-color'); + tableCellNode.setStyles(styleMap); tableCellNode.setAlignment(extractAlignmentFromElement(domNode)); } + const background = style.backgroundColor || null; + if (background) { + tableCellNode.setBackgroundColor(background); + } + return { after: (childLexicalNodes) => { if (childLexicalNodes.length === 0) { diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts index 54cd8b54f..6a415d008 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts @@ -38,7 +38,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split paragraph in between two text nodes', expectedHtml: - '

Hello

world

', + '

Hello

\n

world

', initialHtml: '

Helloworld

', splitOffset: 1, splitPath: [0], @@ -46,7 +46,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split paragraph before the first text node', expectedHtml: - '


Helloworld

', + '


\n

Helloworld

', initialHtml: '

Helloworld

', splitOffset: 0, splitPath: [0], @@ -54,7 +54,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split paragraph after the last text node', expectedHtml: - '

Helloworld


', + '

Helloworld

\n


', initialHtml: '

Helloworld

', splitOffset: 2, // Any offset that is higher than children size splitPath: [0], @@ -62,7 +62,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split list items between two text nodes', expectedHtml: - '
  • Hello
' + + '
  • Hello
\n' + '
  • world
', initialHtml: '
  • Helloworld
', splitOffset: 1, // Any offset that is higher than children size @@ -71,7 +71,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split list items before the first text node', expectedHtml: - '
' + + '
\n' + '
  • Helloworld
', initialHtml: '
  • Helloworld
', splitOffset: 0, // Any offset that is higher than children size @@ -83,7 +83,7 @@ describe('LexicalUtils#splitNode', () => { '
    ' + '
  • Before
  • ' + '
    • Hello
  • ' + - '
' + + '\n' + '
    ' + '
    • world
  • ' + '
  • After
  • ' + diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts index 8c31496de..f13aed408 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts @@ -46,7 +46,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert into paragraph in between two text nodes', expectedHtml: - '

    Hello

    world

    ', + '

    Hello

    \n\n

    world

    ', initialHtml: '

    Helloworld

    ', selectionOffset: 5, // Selection on text node after "Hello" world selectionPath: [0, 0], @@ -57,8 +57,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { '
      ' + '
    • Before
    • ' + '
      • Hello
    • ' + - '
    ' + - '' + + '
\n' + + '\n' + '
    ' + '
    • world
  • ' + '
  • After
  • ' + @@ -74,7 +74,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { }, { _: 'insert into empty paragraph', - expectedHtml: '



    ', + expectedHtml: '


    \n\n


    ', initialHtml: '

    ', selectionOffset: 0, // Selection on text node after "Hello" world selectionPath: [0], @@ -82,8 +82,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert in the end of paragraph', expectedHtml: - '

    Hello world

    ' + - '' + + '

    Hello world

    \n' + + '\n' + '


    ', initialHtml: '

    Hello world

    ', selectionOffset: 12, // Selection on text node after "Hello" world @@ -92,8 +92,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert in the beginning of paragraph', expectedHtml: - '


    ' + - '' + + '


    \n' + + '\n' + '

    Hello world

    ', initialHtml: '

    Hello world

    ', selectionOffset: 0, // Selection on text node after "Hello" world @@ -102,9 +102,9 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert with selection on root start', expectedHtml: - '' + - '' + - '

    Before

    ' + + '\n' + + '\n' + + '

    Before

    \n' + '

    After

    ', initialHtml: '' + @@ -116,8 +116,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert with selection on root child', expectedHtml: - '

    Before

    ' + - '' + + '

    Before

    \n' + + '\n' + '

    After

    ', initialHtml: '

    Before

    After

    ', selectionOffset: 1, @@ -126,7 +126,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert with selection on root end', expectedHtml: - '

    Before

    ' + + '

    Before

    \n' + '', initialHtml: '

    Before

    ', selectionOffset: 1, diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index c1db0f086..413e2c4cd 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -20,9 +20,6 @@ import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {CaptionNode} from "@lexical/table/LexicalCaptionNode"; -/** - * Load the nodes for lexical. - */ export function getNodesForPageEditor(): (KlassConstructor | LexicalNodeReplacement)[] { return [ CalloutNode, @@ -45,6 +42,15 @@ export function getNodesForPageEditor(): (KlassConstructor | ]; } +export function getNodesForBasicEditor(): (KlassConstructor | LexicalNodeReplacement)[] { + return [ + ListNode, + ListItemNode, + ParagraphNode, + LinkNode, + ]; +} + export function registerCommonNodeMutationListeners(context: EditorUiContext): void { const decorated = [ImageNode, CodeBlockNode, DiagramNode]; @@ -53,7 +59,7 @@ export function registerCommonNodeMutationListeners(context: EditorUiContext): v if (mutation === "destroyed") { const decorator = context.manager.getDecoratorByNodeKey(nodeKey); if (decorator) { - decorator.destroy(context); + decorator.teardown(); } } } diff --git a/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts b/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts new file mode 100644 index 000000000..a3da35206 --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts @@ -0,0 +1,51 @@ +import { + createTestContext, destroyFromContext, dispatchEditorMouseClick, +} from "lexical/__tests__/utils"; +import { + $getRoot, LexicalEditor, LexicalNode, + ParagraphNode, +} from "lexical"; +import {registerRichText} from "@lexical/rich-text"; +import {EditorUiContext} from "../../ui/framework/core"; +import {registerMouseHandling} from "../mouse-handling"; +import {$createTableNode, TableNode} from "@lexical/table"; + +describe('Mouse-handling service tests', () => { + + let context!: EditorUiContext; + let editor!: LexicalEditor; + + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + registerRichText(editor); + registerMouseHandling(context); + }); + + afterEach(() => { + destroyFromContext(context); + }); + + test('Click below last table inserts new empty paragraph', () => { + let tableNode!: TableNode; + let lastRootChild!: LexicalNode|null; + + editor.updateAndCommit(() => { + tableNode = $createTableNode(); + $getRoot().append(tableNode); + lastRootChild = $getRoot().getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(TableNode); + + const tableDOM = editor.getElementByKey(tableNode.getKey()); + const rect = tableDOM?.getBoundingClientRect(); + dispatchEditorMouseClick(editor, 0, (rect?.bottom || 0) + 1) + + editor.getEditorState().read(() => { + lastRootChild = $getRoot().getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/mouse-handling.ts b/resources/js/wysiwyg/services/mouse-handling.ts new file mode 100644 index 000000000..058efc8d2 --- /dev/null +++ b/resources/js/wysiwyg/services/mouse-handling.ts @@ -0,0 +1,63 @@ +import {EditorUiContext} from "../ui/framework/core"; +import { + $createParagraphNode, $getRoot, + $getSelection, + $isDecoratorNode, CLICK_COMMAND, + COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ENTER_COMMAND, KEY_TAB_COMMAND, + LexicalEditor, + LexicalNode +} from "lexical"; +import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; +import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; +import {getLastSelection} from "../utils/selection"; +import {$getNearestNodeBlockParent, $getParentOfType, $selectOrCreateAdjacent} from "../utils/nodes"; +import {$setInsetForSelection} from "../utils/lists"; +import {$isListItemNode} from "@lexical/list"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {$isDiagramNode} from "../utils/diagrams"; +import {$isTableNode} from "@lexical/table"; + +function isHardToEscapeNode(node: LexicalNode): boolean { + return $isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node) || $isTableNode(node); +} + +function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boolean { + const lastNode = $getRoot().getLastChild(); + if (!lastNode || !isHardToEscapeNode(lastNode)) { + return false; + } + + const lastNodeDom = context.editor.getElementByKey(lastNode.getKey()); + if (!lastNodeDom) { + return false; + } + + const nodeBounds = lastNodeDom.getBoundingClientRect(); + const isClickBelow = event.clientY > nodeBounds.bottom; + if (isClickBelow) { + context.editor.update(() => { + const newNode = $createParagraphNode(); + $getRoot().append(newNode); + newNode.select(); + }); + return true; + } + + return false; +} + + +export function registerMouseHandling(context: EditorUiContext): () => void { + const unregisterClick = context.editor.registerCommand(CLICK_COMMAND, (event): boolean => { + insertBelowLastNode(context, event); + return false; + }, COMMAND_PRIORITY_LOW); + + + return () => { + unregisterClick(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index 0384a3bf1..ead4c38d4 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -13,14 +13,16 @@ import {$showLinkForm} from "../ui/defaults/forms/objects"; import {showLinkSelector} from "../utils/links"; import {HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; -function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean { - toggleSelectionAsHeading(editor, tag); +function headerHandler(context: EditorUiContext, tag: HeadingTagType): boolean { + toggleSelectionAsHeading(context.editor, tag); + context.manager.triggerFutureStateRefresh(); return true; } function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction { - return (editor: LexicalEditor) => { + return (editor: LexicalEditor, context: EditorUiContext) => { formatAction(editor); + context.manager.triggerFutureStateRefresh(); return true; }; } @@ -45,10 +47,10 @@ const actionsByKeys: Record = { window.$events.emit('editor-save-page'); return true; }, - 'meta+1': (editor) => headerHandler(editor, 'h1'), - 'meta+2': (editor) => headerHandler(editor, 'h2'), - 'meta+3': (editor) => headerHandler(editor, 'h3'), - 'meta+4': (editor) => headerHandler(editor, 'h4'), + 'meta+1': (editor, context) => headerHandler(context, 'h2'), + 'meta+2': (editor, context) => headerHandler(context, 'h3'), + 'meta+3': (editor, context) => headerHandler(context, 'h4'), + 'meta+4': (editor, context) => headerHandler(context, 'h5'), 'meta+5': wrapFormatAction(toggleSelectionAsParagraph), 'meta+d': wrapFormatAction(toggleSelectionAsParagraph), 'meta+6': wrapFormatAction(toggleSelectionAsBlockquote), diff --git a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts index c5b7ad29a..dea78d24f 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts @@ -13,7 +13,6 @@ import codeIcon from "@icons/editor/code.svg"; import formatClearIcon from "@icons/editor/format-clear.svg"; import {$selectionContainsTextFormat} from "../../../utils/selection"; import {$patchStyleText} from "@lexical/selection"; -import {context} from "esbuild"; function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition { return { @@ -32,7 +31,7 @@ export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', bo export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon); export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon); export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon}; -export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon}; +export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color', icon: highlightIcon}; function colorAction(context: EditorUiContext, property: string, color: string): void { context.editor.update(() => { @@ -44,7 +43,7 @@ function colorAction(context: EditorUiContext, property: string, color: string): } export const textColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color); -export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color); +export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'background-color', color); export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 5b484310d..031e00983 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -75,7 +75,7 @@ export function $showCellPropertiesForm(cell: TableCellNode, context: EditorUiCo border_width: styles.get('border-width') || '', border_style: styles.get('border-style') || '', border_color: styles.get('border-color') || '', - background_color: styles.get('background-color') || '', + background_color: cell.getBackgroundColor() || styles.get('background-color') || '', }); return modalForm; } @@ -91,6 +91,7 @@ export const cellProperties: EditorFormDefinition = { $setTableCellColumnWidth(cell, width); cell.updateTag(formData.get('type')?.toString() || ''); cell.setAlignment((formData.get('h_align')?.toString() || '') as CommonBlockAlignment); + cell.setBackgroundColor(formData.get('background_color')?.toString() || ''); const styles = cell.getStyles(); styles.set('height', formatSizeValue(formData.get('height')?.toString() || '')); @@ -98,7 +99,6 @@ export const cellProperties: EditorFormDefinition = { styles.set('border-width', formatSizeValue(formData.get('border_width')?.toString() || '')); styles.set('border-style', formData.get('border_style')?.toString() || ''); styles.set('border-color', formData.get('border_color')?.toString() || ''); - styles.set('background-color', formData.get('background_color')?.toString() || ''); cell.setStyles(styles); } diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index cdc451d08..33468e0a2 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -79,6 +79,7 @@ import { import {el} from "../../utils/dom"; import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu"; import {EditorSeparator} from "../framework/blocks/separator"; +import {EditorContextToolbarDefinition} from "../framework/toolbars"; export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement { @@ -220,50 +221,74 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai ]); } -export function getImageToolbarContent(): EditorUiElement[] { - return [new EditorButton(image)]; -} - -export function getMediaToolbarContent(): EditorUiElement[] { - return [new EditorButton(media)]; -} - -export function getLinkToolbarContent(): EditorUiElement[] { - return [ +export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement { + return new EditorSimpleClassContainer('editor-toolbar-main', [ + new EditorButton(bold), + new EditorButton(italic), new EditorButton(link), - new EditorButton(unlink), - ]; + new EditorButton(bulletList), + new EditorButton(numberList), + ]); } -export function getCodeToolbarContent(): EditorUiElement[] { - return [ - new EditorButton(editCodeBlock), - ]; -} - -export function getTableToolbarContent(): EditorUiElement[] { - return [ - new EditorOverflowContainer(2, [ - new EditorButton(tableProperties), - new EditorButton(deleteTable), - ]), - new EditorOverflowContainer(3, [ - new EditorButton(insertRowAbove), - new EditorButton(insertRowBelow), - new EditorButton(deleteRow), - ]), - new EditorOverflowContainer(3, [ - new EditorButton(insertColumnBefore), - new EditorButton(insertColumnAfter), - new EditorButton(deleteColumn), - ]), - ]; -} - -export function getDetailsToolbarContent(): EditorUiElement[] { - return [ - new EditorButton(detailsEditLabel), - new EditorButton(detailsToggle), - new EditorButton(detailsUnwrap), - ]; -} \ No newline at end of file +export const contextToolbars: Record = { + image: { + selector: 'img:not([drawio-diagram] img)', + content: () => [new EditorButton(image)], + }, + media: { + selector: '.editor-media-wrap', + content: () => [new EditorButton(media)], + }, + link: { + selector: 'a', + content() { + return [ + new EditorButton(link), + new EditorButton(unlink), + ] + }, + displayTargetLocator(originalTarget: HTMLElement): HTMLElement { + const image = originalTarget.querySelector('img'); + return image || originalTarget; + } + }, + code: { + selector: '.editor-code-block-wrap', + content: () => [new EditorButton(editCodeBlock)], + }, + table: { + selector: 'td,th', + content() { + return [ + new EditorOverflowContainer(2, [ + new EditorButton(tableProperties), + new EditorButton(deleteTable), + ]), + new EditorOverflowContainer(3, [ + new EditorButton(insertRowAbove), + new EditorButton(insertRowBelow), + new EditorButton(deleteRow), + ]), + new EditorOverflowContainer(3, [ + new EditorButton(insertColumnBefore), + new EditorButton(insertColumnAfter), + new EditorButton(deleteColumn), + ]), + ]; + }, + displayTargetLocator(originalTarget: HTMLElement): HTMLElement { + return originalTarget.closest('table') as HTMLTableElement; + } + }, + details: { + selector: 'details', + content() { + return [ + new EditorButton(detailsEditLabel), + new EditorButton(detailsToggle), + new EditorButton(detailsUnwrap), + ] + }, + }, +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index ca2ba40c6..9c524dff0 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -30,6 +30,7 @@ export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDef export abstract class EditorUiElement { protected dom: HTMLElement|null = null; private context: EditorUiContext|null = null; + private abortController: AbortController = new AbortController(); protected abstract buildDOM(): HTMLElement; @@ -79,9 +80,16 @@ export abstract class EditorUiElement { if (target) { target.addEventListener('editor::' + name, ((event: CustomEvent) => { callback(event.detail); - }) as EventListener); + }) as EventListener, { signal: this.abortController.signal }); } } + + teardown(): void { + if (this.dom && this.dom.isConnected) { + this.dom.remove(); + } + this.abortController.abort('teardown'); + } } export class EditorContainerUiElement extends EditorUiElement { @@ -129,6 +137,13 @@ export class EditorContainerUiElement extends EditorUiElement { child.setContext(context); } } + + teardown() { + for (const child of this.children) { + child.teardown(); + } + super.teardown(); + } } export class EditorSimpleClassContainer extends EditorContainerUiElement { diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts index 570b8222b..6ea0b8b39 100644 --- a/resources/js/wysiwyg/ui/framework/decorator.ts +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -48,7 +48,7 @@ export abstract class EditorDecorator { * Destroy this decorator. Used for tear-down operations upon destruction * of the underlying node this decorator is attached to. */ - destroy(context: EditorUiContext): void { + teardown(): void { for (const callback of this.onDestroyCallbacks) { callback(); } diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts index 751c1b3f2..890d5b325 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -41,11 +41,18 @@ export class DropDownManager { constructor() { this.onMenuMouseOver = this.onMenuMouseOver.bind(this); + this.onWindowClick = this.onWindowClick.bind(this); - window.addEventListener('click', (event: MouseEvent) => { - const target = event.target as HTMLElement; - this.closeAllNotContainingElement(target); - }); + window.addEventListener('click', this.onWindowClick); + } + + teardown(): void { + window.removeEventListener('click', this.onWindowClick); + } + + protected onWindowClick(event: MouseEvent): void { + const target = event.target as HTMLElement; + this.closeAllNotContainingElement(target); } protected closeAllNotContainingElement(element: HTMLElement): void { diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 2d15b341b..3f46455da 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -12,6 +12,8 @@ export type SelectionChangeHandler = (selection: BaseSelection|null) => void; export class EditorUIManager { + public dropdowns: DropDownManager = new DropDownManager(); + protected modalDefinitionsByKey: Record = {}; protected activeModalsByKey: Record = {}; protected decoratorConstructorsByType: Record = {}; @@ -21,12 +23,12 @@ export class EditorUIManager { protected contextToolbarDefinitionsByKey: Record = {}; protected activeContextToolbars: EditorContextToolbar[] = []; protected selectionChangeHandlers: Set = new Set(); - - public dropdowns: DropDownManager = new DropDownManager(); + protected domEventAbortController = new AbortController(); + protected teardownCallbacks: (()=>void)[] = []; setContext(context: EditorUiContext) { this.context = context; - this.setupEventListeners(context); + this.setupEventListeners(); this.setupEditor(context.editor); } @@ -99,7 +101,7 @@ export class EditorUIManager { setToolbar(toolbar: EditorContainerUiElement) { if (this.toolbar) { - this.toolbar.getDOMElement().remove(); + this.toolbar.teardown(); } this.toolbar = toolbar; @@ -170,10 +172,40 @@ export class EditorUIManager { return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr'; } + onTeardown(callback: () => void): void { + this.teardownCallbacks.push(callback); + } + + teardown(): void { + this.domEventAbortController.abort('teardown'); + + for (const [_, modal] of Object.entries(this.activeModalsByKey)) { + modal.teardown(); + } + + for (const [_, decorator] of Object.entries(this.decoratorInstancesByNodeKey)) { + decorator.teardown(); + } + + if (this.toolbar) { + this.toolbar.teardown(); + } + + for (const toolbar of this.activeContextToolbars) { + toolbar.teardown(); + } + + this.dropdowns.teardown(); + + for (const callback of this.teardownCallbacks) { + callback(); + } + } + protected updateContextToolbars(update: EditorUiStateUpdate): void { for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) { const toolbar = this.activeContextToolbars[i]; - toolbar.destroy(); + toolbar.teardown(); this.activeContextToolbars.splice(i, 1); } @@ -198,7 +230,7 @@ export class EditorUIManager { contentByTarget.set(targetEl, []) } // @ts-ignore - contentByTarget.get(targetEl).push(...definition.content); + contentByTarget.get(targetEl).push(...definition.content()); } } @@ -253,9 +285,9 @@ export class EditorUIManager { }); } - protected setupEventListeners(context: EditorUiContext) { + protected setupEventListeners() { const layoutUpdate = this.triggerLayoutUpdate.bind(this); - window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true}); - window.addEventListener('resize', layoutUpdate, {passive: true}); + window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true, signal: this.domEventAbortController.signal}); + window.addEventListener('resize', layoutUpdate, {passive: true, signal: this.domEventAbortController.signal}); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts index 3eea62ebb..4dbe9d962 100644 --- a/resources/js/wysiwyg/ui/framework/modals.ts +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -34,8 +34,8 @@ export class EditorFormModal extends EditorContainerUiElement { } hide() { - this.getDOMElement().remove(); this.getContext().manager.setModalInactive(this.key); + this.teardown(); } getForm(): EditorForm { diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts index de2255444..cf5ec4ad1 100644 --- a/resources/js/wysiwyg/ui/framework/toolbars.ts +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -4,7 +4,7 @@ import {el} from "../../utils/dom"; export type EditorContextToolbarDefinition = { selector: string; - content: EditorUiElement[], + content: () => EditorUiElement[], displayTargetLocator?: (originalTarget: HTMLElement) => HTMLElement; }; @@ -60,17 +60,4 @@ export class EditorContextToolbar extends EditorContainerUiElement { const dom = this.getDOMElement(); dom.append(...children.map(child => child.getDOMElement())); } - - protected empty() { - const children = this.getChildren(); - for (const child of children) { - child.getDOMElement().remove(); - } - this.removeChildren(...children); - } - - destroy() { - this.empty(); - this.getDOMElement().remove(); - } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index e7ec6adbc..c48386bb4 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,23 +1,30 @@ import {LexicalEditor} from "lexical"; -import { - getCodeToolbarContent, getDetailsToolbarContent, - getImageToolbarContent, - getLinkToolbarContent, - getMainEditorFullToolbar, getMediaToolbarContent, getTableToolbarContent -} from "./defaults/toolbars"; import {EditorUIManager} from "./framework/manager"; import {EditorUiContext} from "./framework/core"; -import {CodeBlockDecorator} from "./decorators/code-block"; -import {DiagramDecorator} from "./decorators/diagram"; -import {modals} from "./defaults/modals"; +import {el} from "../utils/dom"; + +export function buildEditorUI(containerDOM: HTMLElement, editor: LexicalEditor, options: Record): EditorUiContext { + const editorDOM = el('div', { + contenteditable: 'true', + class: `editor-content-area ${options.editorClass || ''}`, + }); + const scrollDOM = el('div', { + class: 'editor-content-wrap', + }, [editorDOM]); + + containerDOM.append(scrollDOM); + containerDOM.classList.add('editor-container'); + containerDOM.setAttribute('dir', options.textDirection); + if (options.darkMode) { + containerDOM.classList.add('editor-dark'); + } -export function buildEditorUI(container: HTMLElement, element: HTMLElement, scrollContainer: HTMLElement, editor: LexicalEditor, options: Record): EditorUiContext { const manager = new EditorUIManager(); const context: EditorUiContext = { editor, - containerDOM: container, - editorDOM: element, - scrollDOM: scrollContainer, + containerDOM: containerDOM, + editorDOM: editorDOM, + scrollDOM: scrollDOM, manager, translate(text: string): string { const translations = options.translations; @@ -31,50 +38,5 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro }; manager.setContext(context); - // Create primary toolbar - manager.setToolbar(getMainEditorFullToolbar(context)); - - // Register modals - for (const key of Object.keys(modals)) { - manager.registerModal(key, modals[key]); - } - - // Register context toolbars - manager.registerContextToolbar('image', { - selector: 'img:not([drawio-diagram] img)', - content: getImageToolbarContent(), - }); - manager.registerContextToolbar('media', { - selector: '.editor-media-wrap', - content: getMediaToolbarContent(), - }); - manager.registerContextToolbar('link', { - selector: 'a', - content: getLinkToolbarContent(), - displayTargetLocator(originalTarget: HTMLElement): HTMLElement { - const image = originalTarget.querySelector('img'); - return image || originalTarget; - } - }); - manager.registerContextToolbar('code', { - selector: '.editor-code-block-wrap', - content: getCodeToolbarContent(), - }); - manager.registerContextToolbar('table', { - selector: 'td,th', - content: getTableToolbarContent(), - displayTargetLocator(originalTarget: HTMLElement): HTMLElement { - return originalTarget.closest('table') as HTMLTableElement; - } - }); - manager.registerContextToolbar('details', { - selector: 'details', - content: getDetailsToolbarContent(), - }); - - // Register image decorator listener - manager.registerDecoratorType('code', CodeBlockDecorator); - manager.registerDecoratorType('diagram', DiagramDecorator); - return context; } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/actions.ts b/resources/js/wysiwyg/utils/actions.ts index ae829bae3..b7ce65eeb 100644 --- a/resources/js/wysiwyg/utils/actions.ts +++ b/resources/js/wysiwyg/utils/actions.ts @@ -64,6 +64,6 @@ export function getEditorContentAsHtml(editor: LexicalEditor): Promise { }); } -export function focusEditor(editor: LexicalEditor) { +export function focusEditor(editor: LexicalEditor): void { editor.focus(() => {}, {defaultSelection: "rootStart"}); } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index 8f4a6599f..15cc3cbbe 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -282,6 +282,7 @@ export function $clearTableFormatting(table: TableNode): void { const cells = row.getChildren().filter(c => $isTableCellNode(c)); for (const cell of cells) { cell.setStyles(new Map); + cell.setBackgroundColor(null); cell.clearWidth(); } } diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 633fa78a6..a7f5ab387 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -52,6 +52,25 @@ body.editor-is-fullscreen { flex: 1; } +// Variation specific styles +.comment-editor-container, +.basic-editor-container { + border-left: 1px solid #DDD; + border-right: 1px solid #DDD; + border-bottom: 1px solid #DDD; + border-radius: 3px; + @include mixins.lightDark(border-color, #DDD, #000); + + .editor-toolbar-main { + border-radius: 3px 3px 0 0; + justify-content: end; + } +} + +.basic-editor-container .editor-content-area { + padding-bottom: 0; +} + // Buttons .editor-button { font-size: 12px; @@ -662,6 +681,14 @@ textarea.editor-form-field-input { } } +// Specific field styles +textarea.editor-form-field-input[name="source"] { + width: 1000px; + height: 600px; + max-height: 60vh; + max-width: 80vw; +} + // Editor theme styles .editor-theme-bold { font-weight: bold; diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index b66688f8d..12fb3385f 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -55,12 +55,17 @@ font-style: normal; font-weight: 400; padding: vars.$xs vars.$m; - color: #444; + @include mixins.lightDark(color, #444, #aaa); + @include mixins.lightDark(background-color, #fff, #222); border-radius: 0; + height: 100%; + font-size: 14px; + line-height: 1.2; max-height: 100%; flex: 1; border: 0; width: 100%; + margin: 0; &:focus { outline: 0; } diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 8175db948..48b4b0ca2 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -389,10 +389,12 @@ body.flexbox { .tri-layout-right { grid-area: c; min-width: 0; + position: relative; } .tri-layout-left { grid-area: a; min-width: 0; + position: relative; } @include mixins.larger-than(vars.$bp-xxl) { @@ -431,7 +433,8 @@ body.flexbox { grid-template-areas: "a b b"; grid-template-columns: 1fr 3fr; grid-template-rows: min-content min-content 1fr; - padding-inline-end: vars.$l; + margin-inline-start: (vars.$m + vars.$xxs); + margin-inline-end: (vars.$m + vars.$xxs); } .tri-layout-sides { grid-column-start: a; @@ -452,6 +455,8 @@ body.flexbox { height: 100%; scrollbar-width: none; -ms-overflow-style: none; + padding-inline: vars.$m; + margin-inline: -(vars.$m); &::-webkit-scrollbar { display: none; } @@ -520,4 +525,26 @@ body.flexbox { margin-inline-start: 0; margin-inline-end: 0; } +} + +/** + * Scroll Indicators + */ +.scroll-away-from-top:before, +.scroll-away-from-bottom:after { + content: ''; + display: block; + position: absolute; + @include mixins.lightDark(color, #F2F2F2, #111); + left: 0; + top: 0; + width: 100%; + height: 50px; + background: linear-gradient(to bottom, currentColor, transparent); + z-index: 2; +} +.scroll-away-from-bottom:after { + top: auto; + bottom: 0; + background: linear-gradient(to top, currentColor, transparent); } \ No newline at end of file diff --git a/resources/views/books/parts/form.blade.php b/resources/views/books/parts/form.blade.php index ee261e72d..44d495c27 100644 --- a/resources/views/books/parts/form.blade.php +++ b/resources/views/books/parts/form.blade.php @@ -1,7 +1,3 @@ -@push('head') - -@endpush - {{ csrf_field() }}
    diff --git a/resources/views/chapters/parts/form.blade.php b/resources/views/chapters/parts/form.blade.php index 602693916..70721631d 100644 --- a/resources/views/chapters/parts/form.blade.php +++ b/resources/views/chapters/parts/form.blade.php @@ -1,7 +1,3 @@ -@push('head') - -@endpush - {{ csrf_field() }}
    diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index eadf35187..d70a8c1d9 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -7,7 +7,6 @@ option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}" option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}" option:page-comment:archive-text="{{ $comment->archived ? trans('entities.comment_unarchive_success') : trans('entities.comment_archive_success') }}" - option:page-comment:wysiwyg-language="{{ $locale->htmlLang() }}" option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}" id="comment{{$comment->local_id}}" class="comment-box"> diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index f27127e97..a5f0168a5 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -3,7 +3,6 @@ option:page-comments:created-text="{{ trans('entities.comment_created_success') }}" option:page-comments:count-text="{{ trans('entities.comment_thread_count') }}" option:page-comments:archived-count-text="{{ trans('entities.comment_archived_count') }}" - option:page-comments:wysiwyg-language="{{ $locale->htmlLang() }}" option:page-comments:wysiwyg-text-direction="{{ $locale->htmlDirection() }}" class="comments-list tab-container" aria-label="{{ trans('entities.comments') }}"> @@ -73,7 +72,6 @@ @if(userCan('comment-create-all') || $commentTree->canUpdateAny()) @push('body-end') - @include('form.editor-translations') @include('entities.selector-popup') @endpush diff --git a/resources/views/entities/body-tag-classes.blade.php b/resources/views/entities/body-tag-classes.blade.php index 08427f1a5..f9ba023c3 100644 --- a/resources/views/entities/body-tag-classes.blade.php +++ b/resources/views/entities/body-tag-classes.blade.php @@ -1 +1 @@ -@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity->tags->all()))->generateAsString() . ' ')) \ No newline at end of file +@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity))->generateAsString() . ' ')) \ No newline at end of file diff --git a/resources/views/form/description-html-input.blade.php b/resources/views/form/description-html-input.blade.php index 3cf726ba4..52244eda6 100644 --- a/resources/views/form/description-html-input.blade.php +++ b/resources/views/form/description-html-input.blade.php @@ -1,5 +1,4 @@ diff --git a/resources/views/layouts/tri.blade.php b/resources/views/layouts/tri.blade.php index c3cedf0fb..061cc6994 100644 --- a/resources/views/layouts/tri.blade.php +++ b/resources/views/layouts/tri.blade.php @@ -28,15 +28,15 @@
    diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php index 7790ba5a4..0207d7278 100644 --- a/resources/views/shelves/parts/form.blade.php +++ b/resources/views/shelves/parts/form.blade.php @@ -1,7 +1,3 @@ -@push('head') - -@endpush - {{ csrf_field() }}
    diff --git a/routes/api.php b/routes/api.php index 85e872ba4..99df24aed 100644 --- a/routes/api.php +++ b/routes/api.php @@ -37,6 +37,7 @@ Route::get('books/{id}/export/html', [ExportControllers\BookExportApiController: 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('books/{id}/export/zip', [ExportControllers\BookExportApiController::class, 'exportZip']); Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']); Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']); @@ -47,6 +48,7 @@ Route::get('chapters/{id}/export/html', [ExportControllers\ChapterExportApiContr 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('chapters/{id}/export/zip', [ExportControllers\ChapterExportApiController::class, 'exportZip']); Route::get('pages', [EntityControllers\PageApiController::class, 'list']); Route::post('pages', [EntityControllers\PageApiController::class, 'create']); @@ -58,6 +60,7 @@ Route::get('pages/{id}/export/html', [ExportControllers\PageExportApiController: 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('pages/{id}/export/zip', [ExportControllers\PageExportApiController::class, 'exportZip']); Route::get('image-gallery', [ImageGalleryApiController::class, 'list']); Route::post('image-gallery', [ImageGalleryApiController::class, 'create']); @@ -85,6 +88,12 @@ Route::get('roles/{id}', [RoleApiController::class, 'read']); Route::put('roles/{id}', [RoleApiController::class, 'update']); Route::delete('roles/{id}', [RoleApiController::class, 'delete']); +Route::get('imports', [ExportControllers\ImportApiController::class, 'list']); +Route::post('imports', [ExportControllers\ImportApiController::class, 'create']); +Route::get('imports/{id}', [ExportControllers\ImportApiController::class, 'read']); +Route::post('imports/{id}', [ExportControllers\ImportApiController::class, 'run']); +Route::delete('imports/{id}', [ExportControllers\ImportApiController::class, 'delete']); + Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'list']); Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']); Route::delete('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'destroy']); diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 084cb59bd..22ccfb482 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -287,62 +287,4 @@ class BooksApiTest extends TestCase $resp->assertStatus(204); $this->assertActivityExists('book_delete'); } - - public function test_export_html_endpoint() - { - $this->actingAsApiEditor(); - $book = $this->entities->book(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/html"); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); - } - - public function test_export_plain_text_endpoint() - { - $this->actingAsApiEditor(); - $book = $this->entities->book(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/plaintext"); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); - } - - public function test_export_pdf_endpoint() - { - $this->actingAsApiEditor(); - $book = $this->entities->book(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/pdf"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); - } - - public function test_export_markdown_endpoint() - { - $this->actingAsApiEditor(); - $book = Book::visible()->has('pages')->has('chapters')->first(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/markdown"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"'); - $resp->assertSee('# ' . $book->name); - $resp->assertSee('# ' . $book->pages()->first()->name); - $resp->assertSee('# ' . $book->chapters()->first()->name); - } - - public function test_cant_export_when_not_have_permission() - { - $types = ['html', 'plaintext', 'pdf', 'markdown']; - $this->actingAsApiEditor(); - $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); - - $book = $this->entities->book(); - foreach ($types as $type) { - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/{$type}"); - $this->assertPermissionError($resp); - } - } } diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php index 9698d4dd9..5d7b05308 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -269,61 +269,4 @@ class ChaptersApiTest extends TestCase $resp->assertStatus(204); $this->assertActivityExists('chapter_delete'); } - - public function test_export_html_endpoint() - { - $this->actingAsApiEditor(); - $chapter = $this->entities->chapter(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/html"); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); - } - - public function test_export_plain_text_endpoint() - { - $this->actingAsApiEditor(); - $chapter = $this->entities->chapter(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/plaintext"); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); - } - - public function test_export_pdf_endpoint() - { - $this->actingAsApiEditor(); - $chapter = $this->entities->chapter(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/pdf"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); - } - - public function test_export_markdown_endpoint() - { - $this->actingAsApiEditor(); - $chapter = Chapter::visible()->has('pages')->first(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/markdown"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"'); - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $chapter->pages()->first()->name); - } - - public function test_cant_export_when_not_have_permission() - { - $types = ['html', 'plaintext', 'pdf', 'markdown']; - $this->actingAsApiEditor(); - $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); - - $chapter = Chapter::visible()->has('pages')->first(); - foreach ($types as $type) { - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/{$type}"); - $this->assertPermissionError($resp); - } - } } diff --git a/tests/Api/ExportsApiTest.php b/tests/Api/ExportsApiTest.php new file mode 100644 index 000000000..e1ac698d0 --- /dev/null +++ b/tests/Api/ExportsApiTest.php @@ -0,0 +1,209 @@ +actingAsApiEditor(); + $book = $this->entities->book(); + + $resp = $this->get("/api/books/{$book->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); + } + + public function test_book_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $book = $this->entities->book(); + + $resp = $this->get("/api/books/{$book->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); + } + + public function test_book_pdf_endpoint() + { + $this->actingAsApiEditor(); + $book = $this->entities->book(); + + $resp = $this->get("/api/books/{$book->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); + } + + public function test_book_markdown_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->has('pages')->has('chapters')->first(); + + $resp = $this->get("/api/books/{$book->id}/export/markdown"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"'); + $resp->assertSee('# ' . $book->name); + $resp->assertSee('# ' . $book->pages()->first()->name); + $resp->assertSee('# ' . $book->chapters()->first()->name); + } + + public function test_book_zip_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->has('pages')->has('chapters')->first(); + + $resp = $this->get("/api/books/{$book->id}/export/zip"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.zip"'); + + $zip = ZipTestHelper::extractFromZipResponse($resp); + $this->assertArrayHasKey('book', $zip->data); + } + + public function test_chapter_html_endpoint() + { + $this->actingAsApiEditor(); + $chapter = $this->entities->chapter(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); + } + + public function test_chapter_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $chapter = $this->entities->chapter(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); + } + + public function test_chapter_pdf_endpoint() + { + $this->actingAsApiEditor(); + $chapter = $this->entities->chapter(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); + } + + public function test_chapter_markdown_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->has('pages')->first(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/markdown"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"'); + $resp->assertSee('# ' . $chapter->name); + $resp->assertSee('# ' . $chapter->pages()->first()->name); + } + + public function test_chapter_zip_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->has('pages')->first(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/zip"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.zip"'); + + $zip = ZipTestHelper::extractFromZipResponse($resp); + $this->assertArrayHasKey('chapter', $zip->data); + } + + public function test_page_html_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); + } + + public function test_page_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); + } + + public function test_page_pdf_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); + } + + public function test_page_markdown_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/markdown"); + $resp->assertStatus(200); + $resp->assertSee('# ' . $page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); + } + + public function test_page_zip_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/zip"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.zip"'); + + $zip = ZipTestHelper::extractFromZipResponse($resp); + $this->assertArrayHasKey('page', $zip->data); + } + + public function test_cant_export_when_not_have_permission() + { + $types = ['html', 'plaintext', 'pdf', 'markdown', 'zip']; + $this->actingAsApiEditor(); + $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); + + $book = $this->entities->book(); + foreach ($types as $type) { + $resp = $this->get("/api/books/{$book->id}/export/{$type}"); + $this->assertPermissionError($resp); + } + + $chapter = Chapter::visible()->has('pages')->first(); + foreach ($types as $type) { + $resp = $this->get("/api/chapters/{$chapter->id}/export/{$type}"); + $this->assertPermissionError($resp); + } + + $page = $this->entities->page(); + foreach ($types as $type) { + $resp = $this->get("/api/pages/{$page->id}/export/{$type}"); + $this->assertPermissionError($resp); + } + } +} diff --git a/tests/Api/ImportsApiTest.php b/tests/Api/ImportsApiTest.php new file mode 100644 index 000000000..7026868c9 --- /dev/null +++ b/tests/Api/ImportsApiTest.php @@ -0,0 +1,175 @@ +entities->book(); + $zip = ZipTestHelper::zipUploadFromData([ + 'page' => [ + 'name' => 'My API import page', + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(200); + + $importId = $resp->json('id'); + $import = Import::query()->findOrFail($importId); + $this->assertEquals('page', $import->type); + + $resp = $this->post($this->baseEndpoint . "/{$import->id}", [ + 'parent_type' => 'book', + 'parent_id' => $book->id, + ]); + $resp->assertJson([ + 'name' => 'My API import page', + 'book_id' => $book->id, + ]); + $resp->assertJsonMissingPath('book'); + + $page = Page::query()->where('name', '=', 'My API import page')->first(); + $this->assertEquals('My api tag', $page->tags()->first()->name); + } + + public function test_create_validation_error(): void + { + $zip = ZipTestHelper::zipUploadFromData([ + 'page' => [ + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(422); + $message = $resp->json('message'); + + $this->assertStringContainsString('ZIP upload failed with the following validation errors:', $message); + $this->assertStringContainsString('[page.name] The name field is required.', $message); + } + + public function test_list(): void + { + $imports = Import::factory()->count(10)->create(); + + $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint); + $resp->assertJsonCount(10, 'data'); + $resp->assertJsonPath('total', 10); + + $firstImport = $imports->first(); + $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint . '?filter[id]=' . $firstImport->id); + $resp->assertJsonCount(1, 'data'); + $resp->assertJsonPath('data.0.id', $firstImport->id); + $resp->assertJsonPath('data.0.name', $firstImport->name); + $resp->assertJsonPath('data.0.size', $firstImport->size); + $resp->assertJsonPath('data.0.type', $firstImport->type); + } + + public function test_list_visibility_limited(): void + { + $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->permissions->grantUserRolePermissions($user, ['content-import']); + + $resp = $this->actingAsForApi($user)->get($this->baseEndpoint); + $resp->assertJsonCount(1, 'data'); + $resp->assertJsonPath('data.0.name', 'MySuperUserImport'); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $resp = $this->actingAsForApi($user)->get($this->baseEndpoint); + $resp->assertJsonCount(2, 'data'); + $resp->assertJsonPath('data.1.name', 'MySuperAdminImport'); + } + + public function test_read(): void + { + $zip = ZipTestHelper::zipUploadFromData([ + 'book' => [ + 'name' => 'My API import book', + 'pages' => [ + [ + 'name' => 'My import page', + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ] + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(200); + + $resp = $this->get($this->baseEndpoint . "/{$resp->json('id')}"); + $resp->assertStatus(200); + + $resp->assertJsonPath('details.name', 'My API import book'); + $resp->assertJsonPath('details.pages.0.name', 'My import page'); + $resp->assertJsonPath('details.pages.0.tags.0.name', 'My api tag'); + $resp->assertJsonMissingPath('metadata'); + } + + public function test_delete(): void + { + $import = Import::factory()->create(); + + $resp = $this->actingAsApiAdmin()->delete($this->baseEndpoint . "/{$import->id}"); + $resp->assertStatus(204); + } + + public function test_content_import_permissions_needed(): void + { + $user = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($user, ['access-api']); + $this->actingAsForApi($user); + $requests = [ + ['GET', $this->baseEndpoint], + ['POST', $this->baseEndpoint], + ['GET', $this->baseEndpoint . "/1"], + ['POST', $this->baseEndpoint . "/1"], + ['DELETE', $this->baseEndpoint . "/1"], + ]; + + foreach ($requests as $request) { + [$method, $endpoint] = $request; + $resp = $this->json($method, $endpoint); + $resp->assertStatus(403); + } + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + foreach ($requests as $request) { + [$method, $endpoint] = $request; + $resp = $this->call($method, $endpoint); + $this->assertNotEquals(403, $resp->status(), "A {$method} request to {$endpoint} returned 403"); + } + } +} diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index 22659d5bb..ced8954eb 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -308,60 +308,4 @@ class PagesApiTest extends TestCase $resp->assertStatus(204); $this->assertActivityExists('page_delete', $page); } - - public function test_export_html_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/html"); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); - } - - public function test_export_plain_text_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/plaintext"); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); - } - - public function test_export_pdf_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/pdf"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); - } - - public function test_export_markdown_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/markdown"); - $resp->assertStatus(200); - $resp->assertSee('# ' . $page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); - } - - public function test_cant_export_when_not_have_permission() - { - $types = ['html', 'plaintext', 'pdf', 'markdown']; - $this->actingAsApiEditor(); - $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); - - $page = $this->entities->page(); - foreach ($types as $type) { - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/{$type}"); - $this->assertPermissionError($resp); - } - } } diff --git a/tests/Entity/CommentDisplayTest.php b/tests/Entity/CommentDisplayTest.php index 22e96c250..bffe29fa9 100644 --- a/tests/Entity/CommentDisplayTest.php +++ b/tests/Entity/CommentDisplayTest.php @@ -60,7 +60,6 @@ class CommentDisplayTest extends TestCase $page = $this->entities->page(); $resp = $this->actingAs($editor)->get($page->getUrl()); - $resp->assertSee('tinymce.min.js?', false); $resp->assertSee('window.editor_translations', false); $resp->assertSee('component="entity-selector"', false); @@ -68,7 +67,6 @@ class CommentDisplayTest extends TestCase $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']); $resp = $this->actingAs($editor)->get($page->getUrl()); - $resp->assertDontSee('tinymce.min.js?', false); $resp->assertDontSee('window.editor_translations', false); $resp->assertDontSee('component="entity-selector"', false); @@ -79,7 +77,6 @@ class CommentDisplayTest extends TestCase ]); $resp = $this->actingAs($editor)->get($page->getUrl()); - $resp->assertSee('tinymce.min.js?', false); $resp->assertSee('window.editor_translations', false); $resp->assertSee('component="entity-selector"', false); } diff --git a/tests/Entity/CommentStoreTest.php b/tests/Entity/CommentStoreTest.php index 8b8a5d488..c5fe4ce50 100644 --- a/tests/Entity/CommentStoreTest.php +++ b/tests/Entity/CommentStoreTest.php @@ -193,13 +193,14 @@ class CommentStoreTest extends TestCase { $page = $this->entities->page(); - $script = '

    My lovely comment

    '; + $script = '

    My lovely comment

    '; $this->asAdmin()->postJson("/comment/$page->id", [ 'html' => $script, ]); $pageView = $this->get($page->getUrl()); $pageView->assertDontSee($script, false); + $pageView->assertDontSee('sneakyscript', false); $pageView->assertSee('

    My lovely comment

    ', false); $comment = $page->comments()->first(); @@ -209,6 +210,7 @@ class CommentStoreTest extends TestCase $pageView = $this->get($page->getUrl()); $pageView->assertDontSee($script, false); + $pageView->assertDontSee('sneakyscript', false); $pageView->assertSee('

    My lovely comment

    updated

    '); } @@ -216,7 +218,7 @@ class CommentStoreTest extends TestCase { $page = $this->entities->page(); Comment::factory()->create([ - 'html' => '

    scriptincommentest

    ', + 'html' => '

    scriptincommentest

    ', 'entity_type' => 'page', 'entity_id' => $page ]); @@ -229,7 +231,7 @@ class CommentStoreTest extends TestCase public function test_comment_html_is_limited() { $page = $this->entities->page(); - $input = '

    Test

    Contenta

    Hello

    '; + $input = '

    Test

    Contenta

    Hello
    there

    '; $expected = '

    Contenta

    '; $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); @@ -248,4 +250,27 @@ class CommentStoreTest extends TestCase 'html' => $expected, ]); } + + public function test_comment_html_spans_are_cleaned() + { + $page = $this->entities->page(); + $input = '

    Hello do you have biscuits?

    '; + $expected = '

    Hello do you have biscuits?

    '; + + $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); + $resp->assertOk(); + $this->assertDatabaseHas('comments', [ + 'entity_type' => 'page', + 'entity_id' => $page->id, + 'html' => $expected, + ]); + + $comment = $page->comments()->first(); + $resp = $this->put("/comment/{$comment->id}", ['html' => $input]); + $resp->assertOk(); + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => $expected, + ]); + } } diff --git a/tests/Entity/TagTest.php b/tests/Entity/TagTest.php index 729f93903..63f037d9c 100644 --- a/tests/Entity/TagTest.php +++ b/tests/Entity/TagTest.php @@ -230,4 +230,39 @@ class TagTest extends TestCase $resp->assertDontSee('tag-name-<>', false); $resp->assertSee('tag-name-<>', false); } + + public function test_parent_tag_classes_visible() + { + $page = $this->entities->pageWithinChapter(); + $page->chapter->tags()->create(['name' => 'My Chapter Tag', 'value' => 'abc123']); + $page->book->tags()->create(['name' => 'My Book Tag', 'value' => 'def456']); + $this->asEditor(); + + $html = $this->withHtml($this->get($page->getUrl())); + $html->assertElementExists('body.chapter-tag-pair-mychaptertag-abc123'); + $html->assertElementExists('body.book-tag-pair-mybooktag-def456'); + + $html = $this->withHtml($this->get($page->chapter->getUrl())); + $html->assertElementExists('body.book-tag-pair-mybooktag-def456'); + } + + public function test_parent_tag_classes_not_visible_if_cannot_see_parent() + { + $page = $this->entities->pageWithinChapter(); + $page->chapter->tags()->create(['name' => 'My Chapter Tag', 'value' => 'abc123']); + $page->book->tags()->create(['name' => 'My Book Tag', 'value' => 'def456']); + $editor = $this->users->editor(); + $this->actingAs($editor); + + $this->permissions->setEntityPermissions($page, ['view'], [$editor->roles()->first()]); + $this->permissions->disableEntityInheritedPermissions($page->chapter); + + $html = $this->withHtml($this->get($page->getUrl())); + $html->assertElementNotExists('body.chapter-tag-pair-mychaptertag-abc123'); + $html->assertElementExists('body.book-tag-pair-mybooktag-def456'); + + $this->permissions->disableEntityInheritedPermissions($page->book); + $html = $this->withHtml($this->get($page->getUrl())); + $html->assertElementNotExists('body.book-tag-pair-mybooktag-def456'); + } } diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 1434c013f..1310dcc24 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -41,7 +41,7 @@ class ZipExportTest extends TestCase { $page = $this->entities->page(); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertEquals($page->id, $zip->data['page']['id'] ?? null); $this->assertArrayNotHasKey('book', $zip->data); @@ -83,7 +83,7 @@ class ZipExportTest extends TestCase { $page = $this->entities->page(); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertEquals([ @@ -105,7 +105,7 @@ class ZipExportTest extends TestCase $page->save(); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertEquals($markdown, $pageData['markdown']); @@ -121,7 +121,7 @@ class ZipExportTest extends TestCase ]); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertEquals([ @@ -147,7 +147,7 @@ class ZipExportTest extends TestCase $image = Image::findOrFail($result['response']->id); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertCount(1, $pageData['images']); @@ -173,7 +173,7 @@ class ZipExportTest extends TestCase $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'PageAttachmentExport.txt', $contents, 'text/plain'); $zipResp = $this->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertCount(1, $pageData['attachments']); @@ -203,7 +203,7 @@ class ZipExportTest extends TestCase ]); $zipResp = $this->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertCount(1, $pageData['attachments']); @@ -221,7 +221,7 @@ class ZipExportTest extends TestCase $book->tags()->saveMany(Tag::factory()->count(2)->make()); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertArrayHasKey('book', $zip->data); $bookData = $zip->data['book']; @@ -243,7 +243,7 @@ class ZipExportTest extends TestCase $coverImage = $book->cover()->first(); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertArrayHasKey('cover', $zip->data['book']); $coverRef = $zip->data['book']['cover']; @@ -258,7 +258,7 @@ class ZipExportTest extends TestCase $chapter->tags()->saveMany(Tag::factory()->count(2)->make()); $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertArrayHasKey('chapter', $zip->data); $chapterData = $zip->data['chapter']; @@ -284,18 +284,18 @@ class ZipExportTest extends TestCase $page->save(); $zipResp = $this->actingAs($editor)->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertCount(0, $zip->data['book']['chapters'][0]['pages'] ?? ['cat']); $zipResp = $this->actingAs($editor)->get($chapter->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertCount(0, $zip->data['chapter']['pages'] ?? ['cat']); $page->chapter_id = 0; $page->save(); $zipResp = $this->actingAs($editor)->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertCount(0, $zip->data['book']['pages'] ?? ['cat']); } @@ -314,7 +314,7 @@ class ZipExportTest extends TestCase $page->save(); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $bookData = $zip->data['book']; $chapterData = $bookData['chapters'][0]; $pageData = $chapterData['pages'][0]; @@ -342,7 +342,7 @@ class ZipExportTest extends TestCase $chapter->save(); $zipResp = $this->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $bookData = $zip->data['book']; $chapterData = $bookData['chapters'][0]; @@ -367,7 +367,7 @@ class ZipExportTest extends TestCase $page->save(); $zipResp = $this->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $ref = '[[bsexport:image:' . $image->id . ']]'; @@ -381,7 +381,7 @@ class ZipExportTest extends TestCase $page->save(); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertStringContainsString('href="' . $page->book->getUrl() . '"', $pageData['html']); @@ -402,7 +402,7 @@ class ZipExportTest extends TestCase $page->save(); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertStringContainsString('href="[[bsexport:attachment:' . $attachment->id . ']]?open=true"', $pageData['html']); @@ -417,7 +417,7 @@ class ZipExportTest extends TestCase $page->save(); $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['chapter']['pages'][0]; $this->assertStringContainsString("[Link to chapter]([[bsexport:chapter:{$chapter->id}]])", $pageData['markdown']); @@ -444,30 +444,4 @@ class ZipExportTest extends TestCase } $this->get($page->getUrl("/export/zip"))->assertStatus(429); } - - 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/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php index d830d8eb6..50517a87d 100644 --- a/tests/Exports/ZipTestHelper.php +++ b/tests/Exports/ZipTestHelper.php @@ -4,6 +4,7 @@ namespace Tests\Exports; use BookStack\Exports\Import; use Illuminate\Http\UploadedFile; +use Illuminate\Testing\TestResponse; use ZipArchive; class ZipTestHelper @@ -56,4 +57,30 @@ class ZipTestHelper return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); } + + public static function extractFromZipResponse(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/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php index 5d354e553..fe98e3208 100644 --- a/tests/SecurityHeaderTest.php +++ b/tests/SecurityHeaderTest.php @@ -17,7 +17,7 @@ class SecurityHeaderTest extends TestCase public function test_cookies_samesite_none_when_iframe_hosts_set() { - $this->runWithEnv('ALLOWED_IFRAME_HOSTS', 'http://example.com', function () { + $this->runWithEnv(['ALLOWED_IFRAME_HOSTS' => 'http://example.com'], function () { $resp = $this->get('/'); foreach ($resp->headers->getCookies() as $cookie) { $this->assertEquals('none', $cookie->getSameSite()); @@ -27,14 +27,14 @@ class SecurityHeaderTest extends TestCase public function test_secure_cookies_controlled_by_app_url() { - $this->runWithEnv('APP_URL', 'http://example.com', function () { + $this->runWithEnv(['APP_URL' => 'http://example.com'], function () { $resp = $this->get('/'); foreach ($resp->headers->getCookies() as $cookie) { $this->assertFalse($cookie->isSecure()); } }); - $this->runWithEnv('APP_URL', 'https://example.com', function () { + $this->runWithEnv(['APP_URL' => 'https://example.com'], function () { $resp = $this->get('/'); foreach ($resp->headers->getCookies() as $cookie) { $this->assertTrue($cookie->isSecure()); @@ -52,7 +52,7 @@ class SecurityHeaderTest extends TestCase public function test_iframe_csp_includes_extra_hosts_if_configured() { - $this->runWithEnv('ALLOWED_IFRAME_HOSTS', 'https://a.example.com https://b.example.com', function () { + $this->runWithEnv(['ALLOWED_IFRAME_HOSTS' => 'https://a.example.com https://b.example.com'], function () { $resp = $this->get('/'); $frameHeader = $this->getCspHeader($resp, 'frame-ancestors'); diff --git a/tests/TestCase.php b/tests/TestCase.php index 0fb899ea9..a8636fb15 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -118,15 +118,18 @@ abstract class TestCase extends BaseTestCase * Database config is juggled so the value can be restored when * parallel testing are used, where multiple databases exist. */ - protected function runWithEnv(string $name, $value, callable $callback, bool $handleDatabase = true) + protected function runWithEnv(array $valuesByKey, callable $callback, bool $handleDatabase = true): void { Env::disablePutenv(); - $originalVal = $_SERVER[$name] ?? null; + $originals = []; + foreach ($valuesByKey as $key => $value) { + $originals[$key] = $_SERVER[$key] ?? null; - if (is_null($value)) { - unset($_SERVER[$name]); - } else { - $_SERVER[$name] = $value; + if (is_null($value)) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $value; + } } $database = config('database.connections.mysql_testing.database'); @@ -144,10 +147,12 @@ abstract class TestCase extends BaseTestCase DB::rollBack(); } - if (is_null($originalVal)) { - unset($_SERVER[$name]); - } else { - $_SERVER[$name] = $originalVal; + foreach ($originals as $key => $value) { + if (is_null($value)) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $value; + } } } diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index b3c85d8f7..4dff38418 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -46,7 +46,7 @@ class ThemeTest extends TestCase $functionsFile = theme_path('functions.php'); app()->alias('cat', 'dog'); file_put_contents($functionsFile, "alias('cat', 'dog');});"); - $this->runWithEnv('APP_THEME', $themeFolder, function () { + $this->runWithEnv(['APP_THEME' => $themeFolder], function () { $this->assertEquals('cat', $this->app->getAlias('dog')); }); }); @@ -61,7 +61,7 @@ class ThemeTest extends TestCase $this->expectException(ThemeException::class); $this->expectExceptionMessageMatches('/Failed loading theme functions file at ".*?" with error: Class "BookStack\\\\Biscuits" not found/'); - $this->runWithEnv('APP_THEME', $themeFolder, fn() => null); + $this->runWithEnv(['APP_THEME' => $themeFolder], fn() => null); }); } @@ -504,7 +504,7 @@ END; $this->beforeApplicationDestroyed(fn() => File::deleteDirectory($themeFolderPath)); // Run provided callback with theme env option set - $this->runWithEnv('APP_THEME', $themeFolderName, function () use ($callback, $themeFolderName) { + $this->runWithEnv(['APP_THEME' => $themeFolderName], function () use ($callback, $themeFolderName) { call_user_func($callback, $themeFolderName); }); } diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 2883fddb8..7795a861a 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -16,7 +16,7 @@ class ConfigTest extends TestCase { public function test_filesystem_images_falls_back_to_storage_type_var() { - $this->runWithEnv('STORAGE_TYPE', 'local_secure', function () { + $this->runWithEnv(['STORAGE_TYPE' => 'local_secure'], function () { $this->checkEnvConfigResult('STORAGE_IMAGE_TYPE', 's3', 'filesystems.images', 's3'); $this->checkEnvConfigResult('STORAGE_IMAGE_TYPE', null, 'filesystems.images', 'local_secure'); }); @@ -24,7 +24,7 @@ class ConfigTest extends TestCase public function test_filesystem_attachments_falls_back_to_storage_type_var() { - $this->runWithEnv('STORAGE_TYPE', 'local_secure', function () { + $this->runWithEnv(['STORAGE_TYPE' => 'local_secure'], function () { $this->checkEnvConfigResult('STORAGE_ATTACHMENT_TYPE', 's3', 'filesystems.attachments', 's3'); $this->checkEnvConfigResult('STORAGE_ATTACHMENT_TYPE', null, 'filesystems.attachments', 'local_secure'); }); @@ -114,7 +114,7 @@ class ConfigTest extends TestCase $this->assertEmpty($getStreamOptions()); - $this->runWithEnv('MAIL_VERIFY_SSL', 'false', function () use ($getStreamOptions) { + $this->runWithEnv(['MAIL_VERIFY_SSL' => 'false'], function () use ($getStreamOptions) { $options = $getStreamOptions(); $this->assertArrayHasKey('ssl', $options); $this->assertFalse($options['ssl']['verify_peer']); @@ -124,9 +124,9 @@ class ConfigTest extends TestCase public function test_non_null_mail_encryption_options_enforce_smtp_scheme() { - $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'tls', 'mail.mailers.smtp.tls_required', true); - $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'ssl', 'mail.mailers.smtp.tls_required', true); - $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'null', 'mail.mailers.smtp.tls_required', false); + $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'tls', 'mail.mailers.smtp.require_tls', true); + $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'ssl', 'mail.mailers.smtp.require_tls', true); + $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'null', 'mail.mailers.smtp.require_tls', false); } public function test_smtp_scheme_and_certain_port_forces_tls_usage() @@ -135,29 +135,18 @@ class ConfigTest extends TestCase /** @var EsmtpTransport $transport */ $transport = Mail::mailer('smtp')->getSymfonyTransport(); Mail::purge('smtp'); - return $transport->getTlsRequirement(); + return $transport->isTlsRequired(); }; - config()->set([ - 'mail.mailers.smtp.tls_required' => null, - 'mail.mailers.smtp.port' => 587, - ]); + $runTest = function (string $tlsOption, int $port, bool $expectedResult) use ($isMailTlsRequired) { + $this->runWithEnv(['MAIL_ENCRYPTION' => $tlsOption, 'MAIL_PORT' => $port], function () use ($isMailTlsRequired, $port, $expectedResult) { + $this->assertEquals($expectedResult, $isMailTlsRequired()); + }); + }; - $this->assertFalse($isMailTlsRequired()); - - config()->set([ - 'mail.mailers.smtp.tls_required' => 'tls', - 'mail.mailers.smtp.port' => 587, - ]); - - $this->assertTrue($isMailTlsRequired()); - - config()->set([ - 'mail.mailers.smtp.tls_required' => null, - 'mail.mailers.smtp.port' => 465, - ]); - - $this->assertTrue($isMailTlsRequired()); + $runTest('null', 587, false); + $runTest('tls', 587, true); + $runTest('null', 465, true); } public function test_mysql_host_parsed_as_expected() @@ -174,7 +163,7 @@ class ConfigTest extends TestCase ]; foreach ($cases as $host => [$expectedHost, $expectedPort]) { - $this->runWithEnv("DB_HOST", $host, function () use ($expectedHost, $expectedPort) { + $this->runWithEnv(["DB_HOST" => $host], function () use ($expectedHost, $expectedPort) { $this->assertEquals($expectedHost, config("database.connections.mysql.host")); $this->assertEquals($expectedPort, config("database.connections.mysql.port")); }, false); @@ -185,12 +174,10 @@ class ConfigTest extends TestCase * Set an environment variable of the given name and value * then check the given config key to see if it matches the given result. * Providing a null $envVal clears the variable. - * - * @param mixed $expectedResult */ - protected function checkEnvConfigResult(string $envName, ?string $envVal, string $configKey, $expectedResult) + protected function checkEnvConfigResult(string $envName, ?string $envVal, string $configKey, mixed $expectedResult): void { - $this->runWithEnv($envName, $envVal, function () use ($configKey, $expectedResult) { + $this->runWithEnv([$envName => $envVal], function () use ($configKey, $expectedResult) { $this->assertEquals($expectedResult, config($configKey)); }); } diff --git a/tests/UrlTest.php b/tests/UrlTest.php index c1e133804..c1a4d4f1c 100644 --- a/tests/UrlTest.php +++ b/tests/UrlTest.php @@ -8,14 +8,14 @@ class UrlTest extends TestCase { public function test_url_helper_takes_custom_url_into_account() { - $this->runWithEnv('APP_URL', 'http://example.com/bookstack', function () { + $this->runWithEnv(['APP_URL' => 'http://example.com/bookstack'], function () { $this->assertEquals('http://example.com/bookstack/books', url('/books')); }); } public function test_url_helper_sets_correct_scheme_even_when_request_scheme_is_different() { - $this->runWithEnv('APP_URL', 'https://example.com/', function () { + $this->runWithEnv(['APP_URL' => 'https://example.com/'], function () { $this->get('http://example.com/login')->assertSee('https://example.com/dist/styles.css'); }); } diff --git a/tsconfig.json b/tsconfig.json index 8bffc25f8..dacaefea2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "include": ["resources/js/**/*"], "exclude": ["resources/js/wysiwyg/lexical/yjs/*"], "compilerOptions": { - "target": "es2019", + "target": "es2022", "module": "commonjs", "rootDir": "./resources/js/", "baseUrl": "./",