]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #5607 from BookStackApp/system_info_endpoint
authorDan Brown <redacted>
Thu, 22 May 2025 16:31:32 +0000 (17:31 +0100)
committerGitHub <redacted>
Thu, 22 May 2025 16:31:32 +0000 (17:31 +0100)
API: System info endpoint

54 files changed:
app/Activity/CommentRepo.php
app/Activity/Controllers/CommentController.php
app/Activity/Models/Comment.php
app/Activity/Tools/CommentTree.php
app/Activity/Tools/CommentTreeNode.php [new file with mode: 0644]
app/Config/filesystems.php
app/Uploads/ImageStorageDisk.php
bookstack-system-cli
composer.lock
database/factories/Activity/Models/CommentFactory.php
database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php [new file with mode: 0644]
lang/en/common.php
lang/en/entities.php
lang/lv/activities.php
lang/lv/common.php
lang/lv/editor.php
lang/lv/entities.php
lang/lv/errors.php
lang/lv/preferences.php
lang/lv/settings.php
lang/lv/validation.php
readme.md
resources/icons/archive.svg [new file with mode: 0644]
resources/icons/bookmark.svg [new file with mode: 0644]
resources/js/components/editor-toolbox.ts [moved from resources/js/components/editor-toolbox.js with 57% similarity]
resources/js/components/index.ts
resources/js/components/page-comment-reference.ts [new file with mode: 0644]
resources/js/components/page-comment.js [deleted file]
resources/js/components/page-comment.ts [new file with mode: 0644]
resources/js/components/page-comments.js [deleted file]
resources/js/components/page-comments.ts [new file with mode: 0644]
resources/js/components/pointer.ts [moved from resources/js/components/pointer.js with 50% similarity]
resources/js/components/tabs.ts [moved from resources/js/components/tabs.js with 74% similarity]
resources/js/services/__tests__/translations.test.ts
resources/js/services/components.ts
resources/js/services/dom.ts
resources/js/services/events.ts
resources/js/services/translations.ts
resources/js/services/util.ts
resources/sass/_animations.scss
resources/sass/_components.scss
resources/sass/_content.scss
resources/sass/_pages.scss
resources/views/comments/comment-branch.blade.php
resources/views/comments/comment.blade.php
resources/views/comments/comments.blade.php
resources/views/comments/create.blade.php
resources/views/pages/parts/pointer.blade.php
resources/views/pages/parts/toolbox-comments.blade.php
resources/views/pages/show.blade.php
routes/web.php
tests/Entity/CommentDisplayTest.php [new file with mode: 0644]
tests/Entity/CommentStoreTest.php [moved from tests/Entity/CommentTest.php with 56% similarity]
tests/Uploads/ImageStorageTest.php [new file with mode: 0644]

index 3336e17e98831a21ca02dd255182c4c706e458d3..7005f8fcf83d9dc788262e32cf76bb6f153ea1b5 100644 (file)
@@ -4,6 +4,8 @@ namespace BookStack\Activity;
 
 use BookStack\Activity\Models\Comment;
 use BookStack\Entities\Models\Entity;
+use BookStack\Exceptions\NotifyException;
+use BookStack\Exceptions\PrettyException;
 use BookStack\Facades\Activity as ActivityService;
 use BookStack\Util\HtmlDescriptionFilter;
 
@@ -20,7 +22,7 @@ class CommentRepo
     /**
      * Create a new comment on an entity.
      */
-    public function create(Entity $entity, string $html, ?int $parent_id): Comment
+    public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
     {
         $userId = user()->id;
         $comment = new Comment();
@@ -29,7 +31,8 @@ class CommentRepo
         $comment->created_by = $userId;
         $comment->updated_by = $userId;
         $comment->local_id = $this->getNextLocalId($entity);
-        $comment->parent_id = $parent_id;
+        $comment->parent_id = $parentId;
+        $comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : '';
 
         $entity->comments()->save($comment);
         ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
@@ -52,6 +55,41 @@ class CommentRepo
         return $comment;
     }
 
+
+    /**
+     * Archive an existing comment.
+     */
+    public function archive(Comment $comment): Comment
+    {
+        if ($comment->parent_id) {
+            throw new NotifyException('Only top-level comments can be archived.', '/', 400);
+        }
+
+        $comment->archived = true;
+        $comment->save();
+
+        ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+
+        return $comment;
+    }
+
+    /**
+     * Un-archive an existing comment.
+     */
+    public function unarchive(Comment $comment): Comment
+    {
+        if ($comment->parent_id) {
+            throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
+        }
+
+        $comment->archived = false;
+        $comment->save();
+
+        ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+
+        return $comment;
+    }
+
     /**
      * Delete a comment from the system.
      */
index 52ccc823864e9e6e3221580b080aa0019968eb40..479d57c4db941455bf34d8a158c6e186b12a686c 100644 (file)
@@ -3,6 +3,8 @@
 namespace BookStack\Activity\Controllers;
 
 use BookStack\Activity\CommentRepo;
+use BookStack\Activity\Tools\CommentTree;
+use BookStack\Activity\Tools\CommentTreeNode;
 use BookStack\Entities\Queries\PageQueries;
 use BookStack\Http\Controller;
 use Illuminate\Http\Request;
@@ -26,6 +28,7 @@ class CommentController extends Controller
         $input = $this->validate($request, [
             'html'      => ['required', 'string'],
             'parent_id' => ['nullable', 'integer'],
+            'content_ref' => ['string'],
         ]);
 
         $page = $this->pageQueries->findVisibleById($pageId);
@@ -40,14 +43,12 @@ class CommentController extends Controller
 
         // Create a new comment.
         $this->checkPermission('comment-create-all');
-        $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
+        $contentRef = $input['content_ref'] ?? '';
+        $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
 
         return view('comments.comment-branch', [
             'readOnly' => false,
-            'branch' => [
-                'comment' => $comment,
-                'children' => [],
-            ]
+            'branch' => new CommentTreeNode($comment, 0, []),
         ]);
     }
 
@@ -74,6 +75,46 @@ class CommentController extends Controller
         ]);
     }
 
+    /**
+     * Mark a comment as archived.
+     */
+    public function archive(int $id)
+    {
+        $comment = $this->commentRepo->getById($id);
+        $this->checkOwnablePermission('page-view', $comment->entity);
+        if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
+            $this->showPermissionError();
+        }
+
+        $this->commentRepo->archive($comment);
+
+        $tree = new CommentTree($comment->entity);
+        return view('comments.comment-branch', [
+            'readOnly' => false,
+            'branch' => $tree->getCommentNodeForId($id),
+        ]);
+    }
+
+    /**
+     * Unmark a comment as archived.
+     */
+    public function unarchive(int $id)
+    {
+        $comment = $this->commentRepo->getById($id);
+        $this->checkOwnablePermission('page-view', $comment->entity);
+        if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
+            $this->showPermissionError();
+        }
+
+        $this->commentRepo->unarchive($comment);
+
+        $tree = new CommentTree($comment->entity);
+        return view('comments.comment-branch', [
+            'readOnly' => false,
+            'branch' => $tree->getCommentNodeForId($id),
+        ]);
+    }
+
     /**
      * Delete a comment from the system.
      */
index d0385d3962f63f3766f88a4855cf738194796cd6..91cea4fe0e3b62526ed5cfa982e869ef6a4ef551 100644 (file)
@@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
  * @property int      $entity_id
  * @property int      $created_by
  * @property int      $updated_by
+ * @property string   $content_ref
+ * @property bool     $archived
  */
 class Comment extends Model implements Loggable
 {
index 16f6804ea4244358568c7a5bdc957953c6c93c2d..a05a9d24726f84ead259de6eb98670bd5b3a9881 100644 (file)
@@ -9,7 +9,7 @@ class CommentTree
 {
     /**
      * The built nested tree structure array.
-     * @var array{comment: Comment, depth: int, children: array}[]
+     * @var CommentTreeNode[]
      */
     protected array $tree;
     protected array $comments;
@@ -28,7 +28,7 @@ class CommentTree
 
     public function empty(): bool
     {
-        return count($this->tree) === 0;
+        return count($this->getActive()) === 0;
     }
 
     public function count(): int
@@ -36,9 +36,35 @@ class CommentTree
         return count($this->comments);
     }
 
-    public function get(): array
+    public function getActive(): array
     {
-        return $this->tree;
+        return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
+    }
+
+    public function activeThreadCount(): int
+    {
+        return count($this->getActive());
+    }
+
+    public function getArchived(): array
+    {
+        return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
+    }
+
+    public function archivedThreadCount(): int
+    {
+        return count($this->getArchived());
+    }
+
+    public function getCommentNodeForId(int $commentId): ?CommentTreeNode
+    {
+        foreach ($this->tree as $node) {
+            if ($node->comment->id === $commentId) {
+                return $node;
+            }
+        }
+
+        return null;
     }
 
     public function canUpdateAny(): bool
@@ -54,6 +80,7 @@ class CommentTree
 
     /**
      * @param Comment[] $comments
+     * @return CommentTreeNode[]
      */
     protected function createTree(array $comments): array
     {
@@ -77,26 +104,22 @@ class CommentTree
 
         $tree = [];
         foreach ($childMap[0] ?? [] as $childId) {
-            $tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
+            $tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap);
         }
 
         return $tree;
     }
 
-    protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
+    protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode
     {
         $childIds = $childMap[$id] ?? [];
         $children = [];
 
         foreach ($childIds as $childId) {
-            $children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
+            $children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap);
         }
 
-        return [
-            'comment' => $byId[$id],
-            'depth' => $depth,
-            'children' => $children,
-        ];
+        return new CommentTreeNode($byId[$id], $depth, $children);
     }
 
     protected function loadComments(): array
diff --git a/app/Activity/Tools/CommentTreeNode.php b/app/Activity/Tools/CommentTreeNode.php
new file mode 100644 (file)
index 0000000..7b280bd
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+namespace BookStack\Activity\Tools;
+
+use BookStack\Activity\Models\Comment;
+
+class CommentTreeNode
+{
+    public Comment $comment;
+    public int $depth;
+
+    /**
+     * @var CommentTreeNode[]
+     */
+    public array $children;
+
+    public function __construct(Comment $comment, int $depth, array $children)
+    {
+        $this->comment = $comment;
+        $this->depth = $depth;
+        $this->children = $children;
+    }
+}
index ab507a2f8c6fae9f923d85624c6956dd589ffdef..ab73fec29682f070a3ae35a1a1ba7f4d844a9353 100644 (file)
@@ -34,6 +34,7 @@ return [
             'root'       => public_path(),
             'serve'      => false,
             'throw'      => true,
+            'directory_visibility' => 'public',
         ],
 
         'local_secure_attachments' => [
index 8e364831f75ede661cf7798f45c96f49a1b9ad57..f2667d993a8314e1a16982678fe9211408ea5e40 100644 (file)
@@ -7,6 +7,7 @@ use Illuminate\Contracts\Filesystem\Filesystem;
 use Illuminate\Filesystem\FilesystemAdapter;
 use Illuminate\Support\Facades\Log;
 use League\Flysystem\UnableToSetVisibility;
+use League\Flysystem\Visibility;
 use Symfony\Component\HttpFoundation\StreamedResponse;
 
 class ImageStorageDisk
@@ -85,7 +86,7 @@ class ImageStorageDisk
         // require different ACLs for S3, and this provides us more logical control.
         if ($makePublic && !$this->isS3Like()) {
             try {
-                $this->filesystem->setVisibility($path, 'public');
+                $this->filesystem->setVisibility($path, Visibility::PUBLIC);
             } catch (UnableToSetVisibility $e) {
                 Log::warning("Unable to set visibility for image upload with relative path: {$path}");
             }
index 6283aa0cb41b89164a5fb67a2f208c03475e0d37..c55c5a8a7840523d5de1a2b1ee74c13723e1eb6b 100755 (executable)
Binary files a/bookstack-system-cli and b/bookstack-system-cli differ
index 04dfaa501b9e129ab3e1e132314ad009776a9619..5458bb587a2528a358bd7cece0daa80baac08f1a 100644 (file)
         },
         {
             "name": "aws/aws-sdk-php",
-            "version": "3.343.6",
+            "version": "3.343.13",
             "source": {
                 "type": "git",
                 "url": "https://github.com/aws/aws-sdk-php.git",
-                "reference": "3746aca8cbed5f46beba850e0a480ef58e71b197"
+                "reference": "eb50d111a09ef39675358e74801260ac129ee346"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/3746aca8cbed5f46beba850e0a480ef58e71b197",
-                "reference": "3746aca8cbed5f46beba850e0a480ef58e71b197",
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/eb50d111a09ef39675358e74801260ac129ee346",
+                "reference": "eb50d111a09ef39675358e74801260ac129ee346",
                 "shasum": ""
             },
             "require": {
             "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.343.6"
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.343.13"
             },
-            "time": "2025-05-07T18:10:08+00:00"
+            "time": "2025-05-16T18:24:39+00:00"
         },
         {
             "name": "bacon/bacon-qr-code",
         },
         {
             "name": "phpstan/phpstan",
-            "version": "2.1.14",
+            "version": "2.1.16",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpstan/phpstan.git",
-                "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2"
+                "reference": "b8c1cf533cba0c305d91c6ccd23f3dd0566ba5f9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8f2e03099cac24ff3b379864d171c5acbfc6b9a2",
-                "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b8c1cf533cba0c305d91c6ccd23f3dd0566ba5f9",
+                "reference": "b8c1cf533cba0c305d91c6ccd23f3dd0566ba5f9",
                 "shasum": ""
             },
             "require": {
                     "type": "github"
                 }
             ],
-            "time": "2025-05-02T15:32:28+00:00"
+            "time": "2025-05-16T09:40:10+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
         },
         {
             "name": "phpunit/phpunit",
-            "version": "11.5.19",
+            "version": "11.5.20",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5"
+                "reference": "e6bdea63ecb7a8287d2cdab25bdde3126e0cfe6f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5",
-                "reference": "0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e6bdea63ecb7a8287d2cdab25bdde3126e0cfe6f",
+                "reference": "e6bdea63ecb7a8287d2cdab25bdde3126e0cfe6f",
                 "shasum": ""
             },
             "require": {
             "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.19"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.20"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-05-02T06:56:52+00:00"
+            "time": "2025-05-11T06:39:52+00:00"
         },
         {
             "name": "sebastian/cli-parser",
         },
         {
             "name": "squizlabs/php_codesniffer",
-            "version": "3.12.2",
+            "version": "3.13.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
-                "reference": "6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa"
+                "reference": "65ff2489553b83b4597e89c3b8b721487011d186"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa",
-                "reference": "6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa",
+                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/65ff2489553b83b4597e89c3b8b721487011d186",
+                "reference": "65ff2489553b83b4597e89c3b8b721487011d186",
                 "shasum": ""
             },
             "require": {
                     "type": "thanks_dev"
                 }
             ],
-            "time": "2025-04-13T04:10:18+00:00"
+            "time": "2025-05-11T03:36:00+00:00"
         },
         {
             "name": "ssddanbrown/asserthtml",
index efbd183b31d8cd75dd4a18281e3948f7353878b9..844bc39938188bbe3d2e54d5c7932266862f5e84 100644 (file)
@@ -27,6 +27,8 @@ class CommentFactory extends Factory
             'html'      => $html,
             'parent_id' => null,
             'local_id'  => 1,
+            'content_ref' => '',
+            'archived' => false,
         ];
     }
 }
diff --git a/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php b/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php
new file mode 100644 (file)
index 0000000..794201d
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('comments', function (Blueprint $table) {
+            $table->string('content_ref');
+            $table->boolean('archived')->index();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('comments', function (Blueprint $table) {
+            $table->dropColumn('content_ref');
+            $table->dropColumn('archived');
+        });
+    }
+};
index b05169bb2c46211f2341d2f13284f2f74459080a..06a9e855ce3989f3b7aef22fb75c9a9be6e7fff6 100644 (file)
@@ -30,6 +30,8 @@ return [
     'create' => 'Create',
     'update' => 'Update',
     'edit' => 'Edit',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sort',
     'move' => 'Move',
     'copy' => 'Copy',
index a74785eaacde3ca5316d7153e1e6b139dc603829..6e616ded452c53b1e168da3561ff2bac1259c7c7 100644 (file)
@@ -392,8 +392,11 @@ return [
     'comment' => 'Comment',
     'comments' => 'Comments',
     'comment_add' => 'Add Comment',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Save Comment',
     'comment_new' => 'New Comment',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +405,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
index 3f6e38c13a5564f33c5828cee329b95395f4c21d..38d9ae21766f8b95ab445c40d5cec33a6caecec0 100644 (file)
@@ -85,12 +85,12 @@ return [
     'webhook_delete_notification' => 'Webhook veiksmīgi izdzēsts',
 
     // 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' => 'izveidoja importu',
+    'import_create_notification' => 'Imports veiksmīgi augšupielādēts',
+    'import_run' => 'atjaunoja importu',
+    'import_run_notification' => 'Saturs veiksmīgi importēts',
+    'import_delete' => 'izdzēsa importu',
+    'import_delete_notification' => 'Imports veiksmīgi dzēsts',
 
     // Users
     'user_create' => 'izveidoja lietotāju',
@@ -128,12 +128,12 @@ return [
     'comment_delete'              => 'dzēsa komentāru',
 
     // 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' => 'izveidoja kārtošanas nosacījumu',
+    'sort_rule_create_notification' => 'Kārtošanas nosacījums veiksmīgi izveidots',
+    'sort_rule_update' => 'atjaunoja kārtošanas nosacījumu',
+    'sort_rule_update_notification' => 'Kārtošanas nosacījums veiksmīgi atjaunots',
+    'sort_rule_delete' => 'izdzēsa kārtošanas nosacījumu',
+    'sort_rule_delete_notification' => 'Kārtošanas nosacījums veiksmīgi dzēsts',
 
     // Other
     'permissions_update'          => 'atjaunoja atļaujas',
index 584b24cb2e7544c0679043c0ebf83bd90871c750..05c4629773c71b9a3fa9b72e1d47756156d3011e 100644 (file)
@@ -109,5 +109,5 @@ return [
     'terms_of_service' => 'Pakalpojuma noteikumi',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Meklēt :appName',
 ];
index 05863fd30af489f506a5f29ad7cbe3079478d446..ad794da9c1590b22445422eeea1c46936f626cab 100644 (file)
@@ -13,7 +13,7 @@ return [
     'cancel' => 'Atcelt',
     'save' => 'Saglabāt',
     'close' => 'Aizvērt',
-    'apply' => 'Apply',
+    'apply' => 'Pielietot',
     'undo' => 'Atsaukt',
     'redo' => 'Atcelt atsaukšanu',
     'left' => 'Pa kreisi',
@@ -148,7 +148,7 @@ return [
     'url' => 'URL',
     'text_to_display' => 'Attēlojamais teksts',
     'title' => 'Nosaukums',
-    'browse_links' => 'Browse links',
+    'browse_links' => 'Pārlūkot saites',
     'open_link' => 'Atvērt saiti',
     'open_link_in' => 'Atvērt saiti...',
     'open_link_current' => 'Šis logs',
@@ -165,8 +165,8 @@ return [
     'about' => 'Par redaktoru',
     'about_title' => 'Par WYSIWYG redaktoru',
     'editor_license' => 'Redaktora licence un autortiesības',
-    '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' => 'Šis redaktors ir izveidots, izmantojot :tinyLink, kas ir publicēts ar MIT licenci.',
+    'editor_lexical_license_link' => 'Pilnu licences informāciju var atrast šeit.',
     'editor_tiny_license' => 'Šis redaktors ir izveidots, izmantojot :tinyLink, kas ir publicēts ar MIT licenci.',
     'editor_tiny_license_link' => 'TinyMCE autortiesības un licences detaļas var atrast šeit.',
     'save_continue' => 'Saglabāt lapu un turpināt',
index e9aad99e58155be5b5e0ca8cb9b6f9a7d6a8c3c6..1c6e6def4b9638cbd22a704c47c70f59b12be8d3 100644 (file)
@@ -39,30 +39,30 @@ return [
     'export_pdf' => 'PDF fails',
     'export_text' => 'Vienkāršs teksta fails',
     'export_md' => 'Markdown fails',
-    'export_zip' => 'Portable ZIP',
+    'export_zip' => 'Pārceļams ZIP arhīvs',
     'default_template' => 'Noklusētā lapas sagatave',
-    '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_explain' => 'Norādīt lapas sagatavi, kas tiks izmantota kā noklusētais saturs visām jaunājām lapām šajā grāmatā. Ņemiet vērā, ka tā tiks izmantota tikai tad, ja lapas veidotājam ir skatīšanas tiesības izvēlētajai sagatavei.',
     'default_template_select' => 'Izvēlēt sagataves lapu',
-    'import' => 'Import',
-    'import_validate' => 'Validate Import',
-    'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
-    'import_zip_select' => 'Select ZIP file to upload',
-    'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
-    'import_pending' => 'Pending Imports',
-    'import_pending_none' => 'No imports have been started.',
-    'import_continue' => 'Continue Import',
-    'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
-    'import_details' => 'Import Details',
-    'import_run' => 'Run Import',
-    'import_size' => ':size Import ZIP Size',
-    'import_uploaded_at' => 'Uploaded :relativeTime',
-    'import_uploaded_by' => 'Uploaded by',
-    'import_location' => 'Import Location',
-    'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
-    'import_delete_confirm' => 'Are you sure you want to delete this import?',
-    'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
-    'import_errors' => 'Import Errors',
-    'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'import' => 'Importēt',
+    'import_validate' => 'Pārbaudīt importu',
+    'import_desc' => 'Importēt grāmatas, nodaļas un lapas izmantojot pārceļamu ZIP arhīvu no šīs vai citas sistēmas instances. Izvēlietites ZIP failu, lai turpinātu. Kad fails ir augšupielādēts un pārbaudīts, jūs varēsiet veikt importa uzstādījumus un to apstiprināt nākamajā skatā.',
+    'import_zip_select' => 'Izvēlieties ZIP failu, ko augšupielādēt',
+    'import_zip_validation_errors' => 'Pārbaudot ZIP failu atrastas šādas kļūdas:',
+    'import_pending' => 'Gaidošie importi',
+    'import_pending_none' => 'Neviens imports nav uzsākts.',
+    'import_continue' => 'Turpināt importu',
+    'import_continue_desc' => 'Pārlūkot saturu, kas tiktu importēts no augšupielādētā ZIP faila. Kad esat gatavs, palaidiet importu, lai pievienotu tā saturu šai sistēmai. Augšupielādētais ZIP fails tiks automātiski izvākts pēc veiksmīga importa.',
+    'import_details' => 'Importa detaļas',
+    'import_run' => 'Palaist importu',
+    'import_size' => ':size importa ZIP izmērs',
+    'import_uploaded_at' => 'Augšupielādes laiks :relativeTime',
+    'import_uploaded_by' => 'Augšupielādēja',
+    'import_location' => 'Importa vieta',
+    'import_location_desc' => 'Izvēlieties mērķa vietu jūsu importētajam saturam. Jums būs nepieciešamas attiecīgās piekļuves tiesības, lai izveidotu saturu izvēlētajā vietā.',
+    'import_delete_confirm' => 'Vai tiešām vēlaties dzēst šo importu?',
+    'import_delete_desc' => 'Šis izdzēsīs augšupielādēto importa ZIP failu, un šo darbību nevarēs atcelt.',
+    'import_errors' => 'Importa kļūdas',
+    'import_errors_desc' => 'Importa mēģinājumā atgadījās šīs kļūdas:',
 
     // Permissions and restrictions
     'permissions' => 'Atļaujas',
@@ -247,8 +247,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(Iztīrītais saturs)',
     'pages_edit_switch_to_markdown_stable' => '(Stabilais saturs)',
     'pages_edit_switch_to_wysiwyg' => 'Pārslēgties uz WYSIWYG redaktoru',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'Pārslēgties uz jauno WYSIWYG redaktoru',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(Alfa testēšanā)',
     'pages_edit_set_changelog' => 'Pievienot izmaiņu aprakstu',
     'pages_edit_enter_changelog_desc' => 'Ievadi nelielu aprakstu par vaiktajām izmaiņām',
     'pages_edit_enter_changelog' => 'Izmaiņu apraksts',
@@ -452,9 +452,9 @@ return [
     'watch_desc_comments_page' => 'Paziņot par lapu izmaiņām un jauniem komentāriem.',
     'watch_change_default' => 'Izmainīt noklusētos paziņojumu uzstādījumus',
     'watch_detail_ignore' => 'Ignorēt paziņojumus',
-    'watch_detail_new' => 'Watching for new pages',
-    'watch_detail_updates' => 'Watching new pages and updates',
-    'watch_detail_comments' => 'Watching new pages, updates & comments',
+    'watch_detail_new' => 'Vērot jaunas lapas',
+    'watch_detail_updates' => 'Vērot jaunas lapas un atjauninājumus',
+    'watch_detail_comments' => 'Vērot jaunas lapas, atjauninājumus un komentārus',
     'watch_detail_parent_book' => 'Watching via parent book',
     'watch_detail_parent_book_ignore' => 'Ignoring via parent book',
     'watch_detail_parent_chapter' => 'Watching via parent chapter',
index 40d755e61f3742280e769f6d94341c5c01c8983f..28cc0d892c19699d987ef15f73f515aac58c35fb 100644 (file)
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Jūs nevarat dzēst vienīgo administratoru',
     'users_cannot_delete_guest' => 'Jūs nevarat dzēst lietotāju "viesis"',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Neizdevās izveidot lietotāju, jo neizdevās nosūtīt ielūguma epastu',
 
     // Roles
     'role_cannot_be_edited' => 'Šo lomu nevar rediģēt',
@@ -106,16 +106,16 @@ return [
     'back_soon' => 'Drīz būs atkal pieejams.',
 
     // 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' => 'Nevarēja nolasīt ZIP failu.',
+    'import_zip_cant_decode_data' => 'Nevarēja atrast un nolasīt data.json saturu ZIP failā.',
+    'import_zip_no_data' => 'ZIP faila datos nav atrasts grāmatu, nodaļu vai lapu saturs.',
+    'import_validation_failed' => 'ZIP faila imports ir neveiksmīgs ar šādām kļūdām:',
+    'import_zip_failed_notification' => 'ZIP faila imports ir neveiksmīgs.',
+    'import_perms_books' => 'Jums nav nepieciešamo tiesību izveidot grāmatas.',
+    'import_perms_chapters' => 'Jums nav nepieciešamo tiesību izveidot nodaļas.',
+    'import_perms_pages' => 'Jums nav nepieciešamo tiesību izveidot lapas.',
+    'import_perms_images' => 'Jums nav nepieciešamo tiesību izviedot attēlus.',
+    'import_perms_attachments' => 'Jums nav nepieciešamo tiesību izveidot pielikumus.',
 
     // API errors
     'api_no_authorization_found' => 'Pieprasījumā nav atrasts autorizācijas žetons',
index 18def9f5c7c99a338a24d5883df56470be340bc0..b91cc2e0833cfde343540c439176bc8e45e394d9 100644 (file)
@@ -9,25 +9,25 @@ return [
 
     'shortcuts' => 'Saīsnes',
     'shortcuts_interface' => 'Saskarnes īsceļu iestatījumi',
-    'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
-    'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.',
+    'shortcuts_toggle_desc' => 'Šeit jūs varat ieslēgt vai izslēgt sistēmas saskarnes klaviatūras īsceļus, kas tiek izmantoti navigācijai un darbībām.',
+    'shortcuts_customize_desc' => 'Jūs varat pielāgot katru no zemāk esošajiem īsceļiem. Vienkārši nospiediet nepieciešamo pogu kombināciju pēc tam, kad izvēlēts īsceļa ievadlauks.',
     'shortcuts_toggle_label' => 'Klaviatūras saīsnes ieslēgtas',
     'shortcuts_section_navigation' => 'Navigācija',
     'shortcuts_section_actions' => 'Biežākās darbības',
     'shortcuts_save' => 'Saglabāt saīsnes',
-    'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
+    'shortcuts_overlay_desc' => 'Piezīme: kad īsceļi ir ieslēgti, ir pieejams palīdzības lodziņš, kas parādās, nospiežot "?". Tas attēlos pieejamos īsceļus tajā brīdī pieejamajām darbībām.',
     'shortcuts_update_success' => 'Saīsņu uzstādījumi ir saglabāt!',
-    'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
+    'shortcuts_overview_desc' => 'Pārvaldīt klaviatūras īsceļus, ko var izmantot sistēmas saskarnes navigācijai.',
 
     'notifications' => 'Paziņojumu iestatījumi',
-    'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.',
+    'notifications_desc' => 'Pārvaldiet epasta paziņojumus, ko saņemsiet, kad sistēmā tiek veiktas noteiktas darbības.',
     'notifications_opt_own_page_changes' => 'Paziņot par izmaiņām manās lapās',
     'notifications_opt_own_page_comments' => 'Paziņot par komentāriem manās lapās',
     'notifications_opt_comment_replies' => 'Paziņot par atbildēm uz maniem komentāriem',
     'notifications_save' => 'Saglabāt iestatījumus',
     'notifications_update_success' => 'Paziņojumu iestatījumi ir atjaunoti!',
     'notifications_watched' => 'Vērotie un ignorētie vienumi',
-    'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.',
+    'notifications_watched_desc' => 'Zemāk ir vienumi, kam piemēroti īpaši vērošanas nosacījumi. Lai atjaunintātu savus uzstādījums šiem, apskatiet vienumu un tad sameklējiet vērošanas uzstādījumus sānu kolonnā.',
 
     'auth' => 'Piekļuve un drošība',
     'auth_change_password' => 'Mainīt paroli',
@@ -35,12 +35,12 @@ return [
     'auth_change_password_success' => 'Parole ir nomainīta!',
 
     'profile' => 'Profila informācija',
-    'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.',
+    'profile_desc' => 'Pārvaldiet sava konta detaļas, kas jūs attēlo citiem lietotājiem, kā arī detaļas, kas tiek izmantotas saziņai un sistēmas pielāgošanai.',
     'profile_view_public' => 'Skatīt publisko profilu',
-    'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.',
+    'profile_name_desc' => 'Uzstādiet savu vārdu, kas tiks parādīts citiem lietotājiem sistēmā pie jūsu darbībām un jums piederošā satura.',
     'profile_email_desc' => 'Šis epasts tiks izmantots paziņojumiem un sistēmas piekļuvei, atkarībā no sistēmā uzstādītās autentifikācijas metodes.',
     'profile_email_no_permission' => 'Diemžēl jums nav tiesību mainīt savu epasta adresi. Ja vēlaties to mainīt, jums jāsazinās ar administratoru, lai tas nomaina šo adresi.',
-    'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.',
+    'profile_avatar_desc' => 'Izvēlieties attēlu, kas tiks izmantots, lai jūs attēlotu citiem sistēmas lietotājiem. Ideālā gadījumā šim attēlam jābūt kvadrātaveida, apmēram 256px platumā un augstumā.',
     'profile_admin_options' => 'Administratora iestatījumi',
     'profile_admin_options_desc' => 'Papildus administratora iestatījumus, kā piemēram, lomu piešķiršanu, var atrast jūsu lietotāja kontā ejot uz "Uzstādījumu > Lietotāji".',
 
index fa0e21acc3bff5e9be96242cd8e88c761870b150..ff7db39ef2d0fb5c25b4bddb914c7b987b4d15f1 100644 (file)
@@ -75,34 +75,34 @@ return [
     'reg_confirm_restrict_domain_placeholder' => 'Nav ierobežojumu',
 
     // 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' => 'Kārtošana',
+    'sorting_book_default' => 'Noklusētā grāmatu kārtošana',
+    'sorting_book_default_desc' => 'Izvēlieties noklusēto kārtošanas nosacījumu, ko pielietot jaunām grāmatām. Šis neskars jau esošas grāmatas, un to var izmainīt grāmatas iestatījumos.',
+    'sorting_rules' => 'Kārtošanas noteikumi',
+    'sorting_rules_desc' => 'Šīs ir iepriekš noteiktas kārtošanas darbības, ko var pielietot saturam šajā sistēmā.',
+    'sort_rule_assigned_to_x_books' => 'Pielietots :count grāmatai|Pielietots :count grāmatām',
+    'sort_rule_create' => 'Izveidot kārtošanas nosacījumu',
+    'sort_rule_edit' => 'Rediģēt kārtošanas nosacījumu',
+    'sort_rule_delete' => 'Dzēst kārtošanas nosacījumu',
+    'sort_rule_delete_desc' => 'Izņemt šo kārtošanas nosacījumu no sistēmas. Grāmatām tiks atjaunota manuāla kārtošana.',
+    'sort_rule_delete_warn_books' => 'Šis kārtošanas nosacījums pašlaik tiek izmantots :count grāmatām. Vai tiešām vēlaties to dzēst?',
+    'sort_rule_delete_warn_default' => 'Šis kārtošanas nosacījums pašlaik ir norādīts kā noklusētais. Vai tiešām vēlaties to dzēst?',
+    'sort_rule_details' => 'Kārtošanas nosacījuma detaļas',
+    'sort_rule_details_desc' => 'Uzstādiet nosaukumu šim kārtošanas nosacījumam, kas parādīsies sarakstā, kad lietotājs izvēlēsies kārtošanas veidu.',
+    'sort_rule_operations' => 'Kārtošanas darbības',
+    'sort_rule_operations_desc' => 'Konfigurējiet kārtošanas darbības pārvelkot tās no pieejamo darb\'biu saraksta. Lietošanas procesā darbības tiks piemērotas saraksta kārtībā no augšas uz apakšu. Jekbādas izmaiņas šeit tiks piemērotas grāmatās, kur šis piemērots, pie saglabāšanas.',
+    'sort_rule_available_operations' => 'Pieejamās darbības',
+    'sort_rule_available_operations_empty' => 'Nav atlikušas darbības',
+    'sort_rule_configured_operations' => 'Uzstādītās darbības',
+    'sort_rule_configured_operations_empty' => 'Ievelciet/pievienojiet darbības no "Pieejamo darbību" saraksta',
+    'sort_rule_op_asc' => '(Pieaug.)',
+    'sort_rule_op_desc' => '(Dilst.)',
+    'sort_rule_op_name' => 'Nosaukums - alfabētiski',
+    'sort_rule_op_name_numeric' => 'Nosaukums - numuriski',
+    'sort_rule_op_created_date' => 'Izveidošanas datums',
+    'sort_rule_op_updated_date' => 'Atjaunināšanas datums',
+    'sort_rule_op_chapters_first' => 'Nodaļas pirmās',
+    'sort_rule_op_chapters_last' => 'Nodaļas pēdējās',
 
     // Maintenance settings
     'maint' => 'Apkope',
@@ -192,7 +192,7 @@ return [
     'role_access_api' => 'Piekļūt sistēmas API',
     'role_manage_settings' => 'Pārvaldīt iestatījumus',
     'role_export_content' => 'Eksportēt saturu',
-    'role_import_content' => 'Import content',
+    'role_import_content' => 'Importēt saturu',
     'role_editor_change' => 'Mainīt lapu redaktoru',
     'role_notifications' => 'Saņemt un pārvaldīt paziņojumus',
     'role_asset' => 'Resursa piekļuves tiesības',
index 5b714af8659979ac7989aa44eb737b353d4641fe..dd318119a588f7e3110d4796c4e3f7252b289126 100644 (file)
@@ -105,10 +105,10 @@ return [
     'url'                  => ':attribute formāts nav derīgs.',
     'uploaded'             => 'Fails netika ielādēts. Serveris nevar pieņemt šāda izmēra failus.',
 
-    '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 ir jāatsaucas uz failu ZIP arhīvā.',
+    'zip_file_mime' => ':attribute ir jāatsaucas uz failu ar tipu :validTypes, bet atrasts :foundType.',
+    'zip_model_expected' => 'Sagaidīts datu objekts, bet atrasts ":type".',
+    'zip_unique' => ':attribute jābūt unikālam šim objekta tipam ZIP arhīvā.',
 
     // Custom validation lines
     'custom' => [
index d6641a53bb3d511b715fbad2208a39668c3e76a1..e1c890e0b69e544998a071137d1b1b2be2947c27 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -6,12 +6,12 @@
 [![Build Status](https://github.com/BookStackApp/BookStack/workflows/test-php/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
 [![Lint Status](https://github.com/BookStackApp/BookStack/workflows/lint-php/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
 [![Maintainability](https://api.codeclimate.com/v1/badges/5551731994dd22fa1f4f/maintainability)](https://codeclimate.com/github/BookStackApp/BookStack/maintainability)
-
+<br>
 [![Alternate Source](https://img.shields.io/static/v1?label=Alt+Source&message=Git&color=ef391a&logo=git)](https://source.bookstackapp.com/)
 [![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/)
-[![Discord](https://img.shields.io/static/v1?label=Discord&message=chat&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
-[![Mastodon](https://img.shields.io/static/v1?label=Mastodon&message=@bookstack&color=595aff&logo=mastodon)](https://fosstodon.org/@bookstack)
-
+[![Discord](https://img.shields.io/static/v1?label=Discord&message=chat&color=738adb&logo=discord)](https://www.bookstackapp.com/links/discord)
+[![Mastodon](https://img.shields.io/static/v1?label=Mastodon&message=@bookstack&color=595aff&logo=mastodon)](https://www.bookstackapp.com/links/mastodon)
+<br>
 [![PeerTube](https://img.shields.io/static/v1?label=PeerTube&[email protected]&color=f2690d&logo=peertube)](https://foss.video/c/bookstack)
 [![YouTube](https://img.shields.io/static/v1?label=YouTube&message=bookstackapp&color=ff0000&logo=youtube)](https://www.youtube.com/bookstackapp)
 
@@ -24,7 +24,7 @@ A platform for storing and organising information and documentation. Details for
 * [Screenshots](https://www.bookstackapp.com/#screenshots) 
 * [BookStack Blog](https://www.bookstackapp.com/blog)
 * [Issue List](https://github.com/BookStackApp/BookStack/issues)
-* [Discord Chat](https://discord.gg/ztkBqR2)
+* [Discord Chat](https://www.bookstackapp.com/links/discord)
 * [Support Options](https://www.bookstackapp.com/support/)
 
 ## 📚 Project Definition
@@ -86,6 +86,11 @@ Big thanks to these companies for supporting the project.
     <img width="240" src="https://www.bookstackapp.com/images/sponsors/route4me.png" alt="Route4Me - Route Optimizer and Route Planner Software">
 </a></td>
 </tr>
+<tr>
+<td colspan="2" align="center"><a href="https://phamos.eu" target="_blank">
+    <img width="136" src="https://www.bookstackapp.com/images/sponsors/phamos.png" alt="phamos">
+</a></td>
+</tr>
 </tbody></table>
 
 ## 🛠️ Development & Testing
diff --git a/resources/icons/archive.svg b/resources/icons/archive.svg
new file mode 100644 (file)
index 0000000..90a4f35
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m480-240 160-160-56-56-64 64v-168h-80v168l-64-64-56 56 160 160ZM200-640v440h560v-440H200Zm0 520q-33 0-56.5-23.5T120-200v-499q0-14 4.5-27t13.5-24l50-61q11-14 27.5-21.5T250-840h460q18 0 34.5 7.5T772-811l50 61q9 11 13.5 24t4.5 27v499q0 33-23.5 56.5T760-120H200Zm16-600h528l-34-40H250l-34 40Zm264 300Z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/bookmark.svg b/resources/icons/bookmark.svg
new file mode 100644 (file)
index 0000000..30e487c
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-120v-640q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v640L480-240 200-120Zm80-122 200-86 200 86v-518H280v518Zm0-518h400-400Z"/></svg>
\ No newline at end of file
similarity index 57%
rename from resources/js/components/editor-toolbox.js
rename to resources/js/components/editor-toolbox.ts
index ddb4ff39c7686a285247466b708d32b2f76fbbba..60bdde05efbc6ac598e1d17bc421acb7a0ae58a0 100644 (file)
@@ -1,42 +1,58 @@
 import {Component} from './component';
 
+export interface EditorToolboxChangeEventData {
+    tab: string;
+    open: boolean;
+}
+
 export class EditorToolbox extends Component {
 
+    protected container!: HTMLElement;
+    protected buttons!: HTMLButtonElement[];
+    protected contentElements!: HTMLElement[];
+    protected toggleButton!: HTMLElement;
+    protected editorWrapEl!: HTMLElement;
+
+    protected open: boolean = false;
+    protected tab: string = '';
+
     setup() {
         // Elements
         this.container = this.$el;
-        this.buttons = this.$manyRefs.tabButton;
+        this.buttons = this.$manyRefs.tabButton as HTMLButtonElement[];
         this.contentElements = this.$manyRefs.tabContent;
         this.toggleButton = this.$refs.toggle;
-        this.editorWrapEl = this.container.closest('.page-editor');
+        this.editorWrapEl = this.container.closest('.page-editor') as HTMLElement;
 
         this.setupListeners();
 
         // Set the first tab as active on load
-        this.setActiveTab(this.contentElements[0].dataset.tabContent);
+        this.setActiveTab(this.contentElements[0].dataset.tabContent || '');
     }
 
-    setupListeners() {
+    protected setupListeners(): void {
         // Toolbox toggle button click
         this.toggleButton.addEventListener('click', () => this.toggle());
         // Tab button click
-        this.container.addEventListener('click', event => {
-            const button = event.target.closest('button');
-            if (this.buttons.includes(button)) {
-                const name = button.dataset.tab;
+        this.container.addEventListener('click', (event: MouseEvent) => {
+            const button = (event.target as HTMLElement).closest('button');
+            if (button instanceof HTMLButtonElement && this.buttons.includes(button)) {
+                const name = button.dataset.tab || '';
                 this.setActiveTab(name, true);
             }
         });
     }
 
-    toggle() {
+    protected toggle(): void {
         this.container.classList.toggle('open');
         const isOpen = this.container.classList.contains('open');
         this.toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
         this.editorWrapEl.classList.toggle('toolbox-open', isOpen);
+        this.open = isOpen;
+        this.emitState();
     }
 
-    setActiveTab(tabName, openToolbox = false) {
+    protected setActiveTab(tabName: string, openToolbox: boolean = false): void {
         // Set button visibility
         for (const button of this.buttons) {
             button.classList.remove('active');
@@ -54,6 +70,14 @@ export class EditorToolbox extends Component {
         if (openToolbox && !this.container.classList.contains('open')) {
             this.toggle();
         }
+
+        this.tab = tabName;
+        this.emitState();
+    }
+
+    protected emitState(): void {
+        const data: EditorToolboxChangeEventData = {tab: this.tab, open: this.open};
+        this.$emit('change', data);
     }
 
 }
index 10b8025db630137f4c4512edba8126e5780d8ec8..63e1ad0dbf79eab21d5cd52ebe97c7dc96448676 100644 (file)
@@ -36,6 +36,7 @@ export {NewUserPassword} from './new-user-password';
 export {Notification} from './notification';
 export {OptionalInput} from './optional-input';
 export {PageComment} from './page-comment';
+export {PageCommentReference} from './page-comment-reference';
 export {PageComments} from './page-comments';
 export {PageDisplay} from './page-display';
 export {PageEditor} from './page-editor';
diff --git a/resources/js/components/page-comment-reference.ts b/resources/js/components/page-comment-reference.ts
new file mode 100644 (file)
index 0000000..009e806
--- /dev/null
@@ -0,0 +1,251 @@
+import {Component} from "./component";
+import {findTargetNodeAndOffset, hashElement} from "../services/dom";
+import {el} from "../wysiwyg/utils/dom";
+import commentIcon from "@icons/comment.svg";
+import closeIcon from "@icons/close.svg";
+import {debounce, scrollAndHighlightElement} from "../services/util";
+import {EditorToolboxChangeEventData} from "./editor-toolbox";
+import {TabsChangeEvent} from "./tabs";
+
+/**
+ * Track the close function for the current open marker so it can be closed
+ * when another is opened so we only show one marker comment thread at one time.
+ */
+let openMarkerClose: Function|null = null;
+
+export class PageCommentReference extends Component {
+    protected link!: HTMLLinkElement;
+    protected reference!: string;
+    protected markerWrap: HTMLElement|null = null;
+
+    protected viewCommentText!: string;
+    protected jumpToThreadText!: string;
+    protected closeText!: string;
+
+    setup() {
+        this.link = this.$el as HTMLLinkElement;
+        this.reference = this.$opts.reference;
+        this.viewCommentText = this.$opts.viewCommentText;
+        this.jumpToThreadText = this.$opts.jumpToThreadText;
+        this.closeText = this.$opts.closeText;
+
+        // Show within page display area if seen
+        this.showForDisplay();
+
+        // Handle editor view to show on comments toolbox view
+        window.addEventListener('editor-toolbox-change', ((event: CustomEvent<EditorToolboxChangeEventData>) => {
+            const tabName: string = event.detail.tab;
+            const isOpen = event.detail.open;
+            if (tabName === 'comments' && isOpen && this.link.checkVisibility()) {
+                this.showForEditor();
+            } else {
+                this.hideMarker();
+            }
+        }) as EventListener);
+
+        // Handle visibility changes within editor toolbox archived details dropdown
+        window.addEventListener('toggle', event => {
+            if (event.target instanceof HTMLElement && event.target.contains(this.link)) {
+                window.requestAnimationFrame(() => {
+                    if (this.link.checkVisibility()) {
+                        this.showForEditor();
+                    } else {
+                        this.hideMarker();
+                    }
+                });
+            }
+        }, {capture: true});
+
+        // Handle comments tab changes to hide/show markers & indicators
+        window.addEventListener('tabs-change', ((event: CustomEvent<TabsChangeEvent>) => {
+            const sectionId = event.detail.showing;
+            if (!sectionId.startsWith('comment-tab-panel')) {
+                return;
+            }
+
+            const panel = document.getElementById(sectionId);
+            if (panel?.contains(this.link)) {
+                this.showForDisplay();
+            } else {
+                this.hideMarker();
+            }
+        }) as EventListener);
+    }
+
+    public showForDisplay() {
+        const pageContentArea = document.querySelector('.page-content');
+        if (pageContentArea instanceof HTMLElement && this.link.checkVisibility()) {
+            this.updateMarker(pageContentArea);
+        }
+    }
+
+    protected showForEditor() {
+        const contentWrap = document.querySelector('.editor-content-wrap');
+        if (contentWrap instanceof HTMLElement) {
+            this.updateMarker(contentWrap);
+        }
+
+        const onChange = () => {
+            this.hideMarker();
+            setTimeout(() => {
+                window.$events.remove('editor-html-change', onChange);
+            }, 1);
+        };
+
+        window.$events.listen('editor-html-change', onChange);
+    }
+
+    protected updateMarker(contentContainer: HTMLElement) {
+        // Reset link and existing marker
+        this.link.classList.remove('outdated', 'missing');
+        if (this.markerWrap) {
+            this.markerWrap.remove();
+        }
+
+        const [refId, refHash, refRange] = this.reference.split(':');
+        const refEl = document.getElementById(refId);
+        if (!refEl) {
+            this.link.classList.add('outdated', 'missing');
+            return;
+        }
+
+        const actualHash = hashElement(refEl);
+        if (actualHash !== refHash) {
+            this.link.classList.add('outdated');
+        }
+
+        const marker = el('button', {
+            type: 'button',
+            class: 'content-comment-marker',
+            title: this.viewCommentText,
+        });
+        marker.innerHTML = <string>commentIcon;
+        marker.addEventListener('click', event => {
+            this.showCommentAtMarker(marker);
+        });
+
+        this.markerWrap = el('div', {
+            class: 'content-comment-highlight',
+        }, [marker]);
+
+        contentContainer.append(this.markerWrap);
+        this.positionMarker(refEl, refRange);
+
+        this.link.href = `#${refEl.id}`;
+        this.link.addEventListener('click', (event: MouseEvent) => {
+            event.preventDefault();
+            scrollAndHighlightElement(refEl);
+        });
+
+        const debouncedReposition = debounce(() => {
+            this.positionMarker(refEl, refRange);
+        }, 50, false).bind(this);
+        window.addEventListener('resize', debouncedReposition);
+    }
+
+    protected positionMarker(targetEl: HTMLElement, range: string) {
+        if (!this.markerWrap) {
+            return;
+        }
+
+        const markerParent = this.markerWrap.parentElement as HTMLElement;
+        const parentBounds = markerParent.getBoundingClientRect();
+        let targetBounds = targetEl.getBoundingClientRect();
+        const [rangeStart, rangeEnd] = range.split('-');
+        if (rangeStart && rangeEnd) {
+            const range = new Range();
+            const relStart = findTargetNodeAndOffset(targetEl, Number(rangeStart));
+            const relEnd = findTargetNodeAndOffset(targetEl, Number(rangeEnd));
+            if (relStart && relEnd) {
+                range.setStart(relStart.node, relStart.offset);
+                range.setEnd(relEnd.node, relEnd.offset);
+                targetBounds = range.getBoundingClientRect();
+            }
+        }
+
+        const relLeft = targetBounds.left - parentBounds.left;
+        const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop;
+
+        this.markerWrap.style.left = `${relLeft}px`;
+        this.markerWrap.style.top = `${relTop}px`;
+        this.markerWrap.style.width = `${targetBounds.width}px`;
+        this.markerWrap.style.height = `${targetBounds.height}px`;
+    }
+
+    public hideMarker() {
+        // Hide marker and close existing marker windows
+        if (openMarkerClose) {
+            openMarkerClose();
+        }
+        this.markerWrap?.remove();
+        this.markerWrap = null;
+    }
+
+    protected showCommentAtMarker(marker: HTMLElement): void {
+        // Hide marker and close existing marker windows
+        if (openMarkerClose) {
+            openMarkerClose();
+        }
+        marker.hidden = true;
+
+        // Locate relevant comment
+        const commentBox = this.link.closest('.comment-box') as HTMLElement;
+
+        // Build comment window
+        const readClone = (commentBox.closest('.comment-branch') as HTMLElement).cloneNode(true) as HTMLElement;
+        const toRemove = readClone.querySelectorAll('.actions, form');
+        for (const el of toRemove) {
+            el.remove();
+        }
+
+        const close = el('button', {type: 'button', title: this.closeText});
+        close.innerHTML = (closeIcon as string);
+        const jump = el('button', {type: 'button', 'data-action': 'jump'}, [this.jumpToThreadText]);
+
+        const commentWindow = el('div', {
+            class: 'content-comment-window'
+        }, [
+            el('div', {
+                class: 'content-comment-window-actions',
+            }, [jump, close]),
+            el('div', {
+                class: 'content-comment-window-content comment-container-compact comment-container-super-compact',
+            }, [readClone]),
+        ]);
+
+        marker.parentElement?.append(commentWindow);
+
+        // Handle interaction within window
+        const closeAction = () => {
+            commentWindow.remove();
+            marker.hidden = false;
+            window.removeEventListener('click', windowCloseAction);
+            openMarkerClose = null;
+        };
+
+        const windowCloseAction = (event: MouseEvent) => {
+            if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) {
+                closeAction();
+            }
+        };
+        window.addEventListener('click', windowCloseAction);
+
+        openMarkerClose = closeAction;
+        close.addEventListener('click', closeAction.bind(this));
+        jump.addEventListener('click', () => {
+            closeAction();
+            commentBox.scrollIntoView({behavior: 'smooth'});
+            const highlightTarget = commentBox.querySelector('.header') as HTMLElement;
+            highlightTarget.classList.add('anim-highlight');
+            highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight'))
+        });
+
+        // Position window within bounds
+        const commentWindowBounds = commentWindow.getBoundingClientRect();
+        const contentBounds = document.querySelector('.page-content')?.getBoundingClientRect();
+        if (contentBounds && commentWindowBounds.right > contentBounds.right) {
+            const diff = commentWindowBounds.right - contentBounds.right;
+            commentWindow.style.left = `-${diff}px`;
+        }
+    }
+}
\ No newline at end of file
diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js
deleted file mode 100644 (file)
index 8c0a8b3..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom.ts';
-import {buildForInput} from '../wysiwyg-tinymce/config';
-
-export class PageComment extends Component {
-
-    setup() {
-        // Options
-        this.commentId = this.$opts.commentId;
-        this.commentLocalId = this.$opts.commentLocalId;
-        this.commentParentId = this.$opts.commentParentId;
-        this.deletedText = this.$opts.deletedText;
-        this.updatedText = this.$opts.updatedText;
-
-        // Editor reference and text options
-        this.wysiwygEditor = null;
-        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
-        this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
-
-        // Element references
-        this.container = this.$el;
-        this.contentContainer = this.$refs.contentContainer;
-        this.form = this.$refs.form;
-        this.formCancel = this.$refs.formCancel;
-        this.editButton = this.$refs.editButton;
-        this.deleteButton = this.$refs.deleteButton;
-        this.replyButton = this.$refs.replyButton;
-        this.input = this.$refs.input;
-
-        this.setupListeners();
-    }
-
-    setupListeners() {
-        if (this.replyButton) {
-            this.replyButton.addEventListener('click', () => this.$emit('reply', {
-                id: this.commentLocalId,
-                element: this.container,
-            }));
-        }
-
-        if (this.editButton) {
-            this.editButton.addEventListener('click', this.startEdit.bind(this));
-            this.form.addEventListener('submit', this.update.bind(this));
-            this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
-        }
-
-        if (this.deleteButton) {
-            this.deleteButton.addEventListener('click', this.delete.bind(this));
-        }
-    }
-
-    toggleEditMode(show) {
-        this.contentContainer.toggleAttribute('hidden', show);
-        this.form.toggleAttribute('hidden', !show);
-    }
-
-    startEdit() {
-        this.toggleEditMode(true);
-
-        if (this.wysiwygEditor) {
-            this.wysiwygEditor.focus();
-            return;
-        }
-
-        const config = buildForInput({
-            language: this.wysiwygLanguage,
-            containerElement: this.input,
-            darkMode: document.documentElement.classList.contains('dark-mode'),
-            textDirection: this.wysiwygTextDirection,
-            translations: {},
-            translationMap: window.editor_translations,
-        });
-
-        window.tinymce.init(config).then(editors => {
-            this.wysiwygEditor = editors[0];
-            setTimeout(() => this.wysiwygEditor.focus(), 50);
-        });
-    }
-
-    async update(event) {
-        event.preventDefault();
-        const loading = this.showLoading();
-        this.form.toggleAttribute('hidden', true);
-
-        const reqData = {
-            html: this.wysiwygEditor.getContent(),
-            parent_id: this.parentId || null,
-        };
-
-        try {
-            const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
-            const newComment = htmlToDom(resp.data);
-            this.container.replaceWith(newComment);
-            window.$events.success(this.updatedText);
-        } catch (err) {
-            console.error(err);
-            window.$events.showValidationErrors(err);
-            this.form.toggleAttribute('hidden', false);
-            loading.remove();
-        }
-    }
-
-    async delete() {
-        this.showLoading();
-
-        await window.$http.delete(`/comment/${this.commentId}`);
-        this.$emit('delete');
-        this.container.closest('.comment-branch').remove();
-        window.$events.success(this.deletedText);
-    }
-
-    showLoading() {
-        const loading = getLoading();
-        loading.classList.add('px-l');
-        this.container.append(loading);
-        return loading;
-    }
-
-}
diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts
new file mode 100644 (file)
index 0000000..0c3e19f
--- /dev/null
@@ -0,0 +1,184 @@
+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";
+
+export interface PageCommentReplyEventData {
+    id: string; // ID of comment being replied to
+    element: HTMLElement; // Container for comment replied to
+}
+
+export interface PageCommentArchiveEventData {
+    new_thread_dom: HTMLElement;
+}
+
+export class PageComment extends Component {
+
+    protected commentId!: string;
+    protected commentLocalId!: string;
+    protected deletedText!: string;
+    protected updatedText!: string;
+    protected archiveText!: string;
+
+    protected wysiwygEditor: any = null;
+    protected wysiwygLanguage!: string;
+    protected wysiwygTextDirection!: string;
+
+    protected container!: HTMLElement;
+    protected contentContainer!: HTMLElement;
+    protected form!: HTMLFormElement;
+    protected formCancel!: HTMLElement;
+    protected editButton!: HTMLElement;
+    protected deleteButton!: HTMLElement;
+    protected replyButton!: HTMLElement;
+    protected archiveButton!: HTMLElement;
+    protected input!: HTMLInputElement;
+
+    setup() {
+        // Options
+        this.commentId = this.$opts.commentId;
+        this.commentLocalId = this.$opts.commentLocalId;
+        this.deletedText = this.$opts.deletedText;
+        this.deletedText = this.$opts.deletedText;
+        this.archiveText = this.$opts.archiveText;
+
+        // Editor reference and text options
+        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
+        this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
+
+        // Element references
+        this.container = this.$el;
+        this.contentContainer = this.$refs.contentContainer;
+        this.form = this.$refs.form as HTMLFormElement;
+        this.formCancel = this.$refs.formCancel;
+        this.editButton = this.$refs.editButton;
+        this.deleteButton = this.$refs.deleteButton;
+        this.replyButton = this.$refs.replyButton;
+        this.archiveButton = this.$refs.archiveButton;
+        this.input = this.$refs.input as HTMLInputElement;
+
+        this.setupListeners();
+    }
+
+    protected setupListeners(): void {
+        if (this.replyButton) {
+            const data: PageCommentReplyEventData = {
+                id: this.commentLocalId,
+                element: this.container,
+            };
+            this.replyButton.addEventListener('click', () => this.$emit('reply', data));
+        }
+
+        if (this.editButton) {
+            this.editButton.addEventListener('click', this.startEdit.bind(this));
+            this.form.addEventListener('submit', this.update.bind(this));
+            this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
+        }
+
+        if (this.deleteButton) {
+            this.deleteButton.addEventListener('click', this.delete.bind(this));
+        }
+
+        if (this.archiveButton) {
+            this.archiveButton.addEventListener('click', this.archive.bind(this));
+        }
+    }
+
+    protected toggleEditMode(show: boolean) : void {
+        this.contentContainer.toggleAttribute('hidden', show);
+        this.form.toggleAttribute('hidden', !show);
+    }
+
+    protected startEdit() : void {
+        this.toggleEditMode(true);
+
+        if (this.wysiwygEditor) {
+            this.wysiwygEditor.focus();
+            return;
+        }
+
+        const config = buildForInput({
+            language: this.wysiwygLanguage,
+            containerElement: this.input,
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.wysiwygTextDirection,
+            drawioUrl: '',
+            pageId: 0,
+            translations: {},
+            translationMap: (window as unknown as Record<string, Object>).editor_translations,
+        });
+
+        (window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => {
+            this.wysiwygEditor = editors[0];
+            setTimeout(() => this.wysiwygEditor.focus(), 50);
+        });
+    }
+
+    protected async update(event: Event): Promise<void> {
+        event.preventDefault();
+        const loading = this.showLoading();
+        this.form.toggleAttribute('hidden', true);
+
+        const reqData = {
+            html: this.wysiwygEditor.getContent(),
+        };
+
+        try {
+            const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
+            const newComment = htmlToDom(resp.data as string);
+            this.container.replaceWith(newComment);
+            window.$events.success(this.updatedText);
+        } catch (err) {
+            console.error(err);
+            if (err instanceof HttpError) {
+                window.$events.showValidationErrors(err);
+            }
+            this.form.toggleAttribute('hidden', false);
+            loading.remove();
+        }
+    }
+
+    protected async delete(): Promise<void> {
+        this.showLoading();
+
+        await window.$http.delete(`/comment/${this.commentId}`);
+        this.$emit('delete');
+
+        const branch = this.container.closest('.comment-branch');
+        if (branch instanceof HTMLElement) {
+            const refs = window.$components.allWithinElement<PageCommentReference>(branch, 'page-comment-reference');
+            for (const ref of refs) {
+                ref.hideMarker();
+            }
+            branch.remove();
+        }
+
+        window.$events.success(this.deletedText);
+    }
+
+    protected async archive(): Promise<void> {
+        this.showLoading();
+        const isArchived = this.archiveButton.dataset.isArchived === 'true';
+        const action = isArchived ? 'unarchive' : 'archive';
+
+        const response = await window.$http.put(`/comment/${this.commentId}/${action}`);
+        window.$events.success(this.archiveText);
+        const eventData: PageCommentArchiveEventData = {new_thread_dom: htmlToDom(response.data as string)};
+        this.$emit(action, eventData);
+
+        const branch = this.container.closest('.comment-branch') as HTMLElement;
+        const references = window.$components.allWithinElement<PageCommentReference>(branch, 'page-comment-reference');
+        for (const reference of references) {
+            reference.hideMarker();
+        }
+        branch.remove();
+    }
+
+    protected showLoading(): HTMLElement {
+        const loading = getLoading();
+        loading.classList.add('px-l');
+        this.container.append(loading);
+        return loading;
+    }
+}
diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js
deleted file mode 100644 (file)
index 8f02383..0000000
+++ /dev/null
@@ -1,175 +0,0 @@
-import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom.ts';
-import {buildForInput} from '../wysiwyg-tinymce/config';
-
-export class PageComments extends Component {
-
-    setup() {
-        this.elem = this.$el;
-        this.pageId = Number(this.$opts.pageId);
-
-        // Element references
-        this.container = this.$refs.commentContainer;
-        this.commentCountBar = this.$refs.commentCountBar;
-        this.commentsTitle = this.$refs.commentsTitle;
-        this.addButtonContainer = this.$refs.addButtonContainer;
-        this.replyToRow = this.$refs.replyToRow;
-        this.formContainer = this.$refs.formContainer;
-        this.form = this.$refs.form;
-        this.formInput = this.$refs.formInput;
-        this.formReplyLink = this.$refs.formReplyLink;
-        this.addCommentButton = this.$refs.addCommentButton;
-        this.hideFormButton = this.$refs.hideFormButton;
-        this.removeReplyToButton = this.$refs.removeReplyToButton;
-
-        // WYSIWYG options
-        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
-        this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
-        this.wysiwygEditor = null;
-
-        // Translations
-        this.createdText = this.$opts.createdText;
-        this.countText = this.$opts.countText;
-
-        // Internal State
-        this.parentId = null;
-        this.formReplyText = this.formReplyLink?.textContent || '';
-
-        this.setupListeners();
-    }
-
-    setupListeners() {
-        this.elem.addEventListener('page-comment-delete', () => {
-            setTimeout(() => this.updateCount(), 1);
-            this.hideForm();
-        });
-
-        this.elem.addEventListener('page-comment-reply', event => {
-            this.setReply(event.detail.id, event.detail.element);
-        });
-
-        if (this.form) {
-            this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
-            this.hideFormButton.addEventListener('click', this.hideForm.bind(this));
-            this.addCommentButton.addEventListener('click', this.showForm.bind(this));
-            this.form.addEventListener('submit', this.saveComment.bind(this));
-        }
-    }
-
-    saveComment(event) {
-        event.preventDefault();
-        event.stopPropagation();
-
-        const loading = getLoading();
-        loading.classList.add('px-l');
-        this.form.after(loading);
-        this.form.toggleAttribute('hidden', true);
-
-        const reqData = {
-            html: this.wysiwygEditor.getContent(),
-            parent_id: this.parentId || null,
-        };
-
-        window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
-            const newElem = htmlToDom(resp.data);
-
-            if (reqData.parent_id) {
-                this.formContainer.after(newElem);
-            } else {
-                this.container.append(newElem);
-            }
-
-            window.$events.success(this.createdText);
-            this.hideForm();
-            this.updateCount();
-        }).catch(err => {
-            this.form.toggleAttribute('hidden', false);
-            window.$events.showValidationErrors(err);
-        });
-
-        this.form.toggleAttribute('hidden', false);
-        loading.remove();
-    }
-
-    updateCount() {
-        const count = this.getCommentCount();
-        this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count});
-    }
-
-    resetForm() {
-        this.removeEditor();
-        this.formInput.value = '';
-        this.parentId = null;
-        this.replyToRow.toggleAttribute('hidden', true);
-        this.container.append(this.formContainer);
-    }
-
-    showForm() {
-        this.removeEditor();
-        this.formContainer.toggleAttribute('hidden', false);
-        this.addButtonContainer.toggleAttribute('hidden', true);
-        this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
-        this.loadEditor();
-    }
-
-    hideForm() {
-        this.resetForm();
-        this.formContainer.toggleAttribute('hidden', true);
-        if (this.getCommentCount() > 0) {
-            this.elem.append(this.addButtonContainer);
-        } else {
-            this.commentCountBar.append(this.addButtonContainer);
-        }
-        this.addButtonContainer.toggleAttribute('hidden', false);
-    }
-
-    loadEditor() {
-        if (this.wysiwygEditor) {
-            this.wysiwygEditor.focus();
-            return;
-        }
-
-        const config = buildForInput({
-            language: this.wysiwygLanguage,
-            containerElement: this.formInput,
-            darkMode: document.documentElement.classList.contains('dark-mode'),
-            textDirection: this.wysiwygTextDirection,
-            translations: {},
-            translationMap: window.editor_translations,
-        });
-
-        window.tinymce.init(config).then(editors => {
-            this.wysiwygEditor = editors[0];
-            setTimeout(() => this.wysiwygEditor.focus(), 50);
-        });
-    }
-
-    removeEditor() {
-        if (this.wysiwygEditor) {
-            this.wysiwygEditor.remove();
-            this.wysiwygEditor = null;
-        }
-    }
-
-    getCommentCount() {
-        return this.container.querySelectorAll('[component="page-comment"]').length;
-    }
-
-    setReply(commentLocalId, commentElement) {
-        const targetFormLocation = commentElement.closest('.comment-branch').querySelector('.comment-branch-children');
-        targetFormLocation.append(this.formContainer);
-        this.showForm();
-        this.parentId = commentLocalId;
-        this.replyToRow.toggleAttribute('hidden', false);
-        this.formReplyLink.textContent = this.formReplyText.replace('1234', this.parentId);
-        this.formReplyLink.href = `#comment${this.parentId}`;
-    }
-
-    removeReplyTo() {
-        this.parentId = null;
-        this.replyToRow.toggleAttribute('hidden', true);
-        this.container.append(this.formContainer);
-        this.showForm();
-    }
-
-}
diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts
new file mode 100644 (file)
index 0000000..94f5ab3
--- /dev/null
@@ -0,0 +1,260 @@
+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";
+
+export class PageComments extends Component {
+
+    private elem!: HTMLElement;
+    private pageId!: number;
+    private container!: HTMLElement;
+    private commentCountBar!: HTMLElement;
+    private activeTab!: HTMLElement;
+    private archivedTab!: HTMLElement;
+    private addButtonContainer!: HTMLElement;
+    private archiveContainer!: HTMLElement;
+    private replyToRow!: HTMLElement;
+    private referenceRow!: HTMLElement;
+    private formContainer!: HTMLElement;
+    private form!: HTMLFormElement;
+    private formInput!: HTMLInputElement;
+    private formReplyLink!: HTMLAnchorElement;
+    private formReferenceLink!: HTMLAnchorElement;
+    private addCommentButton!: HTMLElement;
+    private hideFormButton!: HTMLElement;
+    private removeReplyToButton!: HTMLElement;
+    private removeReferenceButton!: HTMLElement;
+    private wysiwygLanguage!: string;
+    private wysiwygTextDirection!: string;
+    private wysiwygEditor: any = null;
+    private createdText!: string;
+    private countText!: string;
+    private archivedCountText!: string;
+    private parentId: number | null = null;
+    private contentReference: string = '';
+    private formReplyText: string = '';
+
+    setup() {
+        this.elem = this.$el;
+        this.pageId = Number(this.$opts.pageId);
+
+        // Element references
+        this.container = this.$refs.commentContainer;
+        this.commentCountBar = this.$refs.commentCountBar;
+        this.activeTab = this.$refs.activeTab;
+        this.archivedTab = this.$refs.archivedTab;
+        this.addButtonContainer = this.$refs.addButtonContainer;
+        this.archiveContainer = this.$refs.archiveContainer;
+        this.replyToRow = this.$refs.replyToRow;
+        this.referenceRow = this.$refs.referenceRow;
+        this.formContainer = this.$refs.formContainer;
+        this.form = this.$refs.form as HTMLFormElement;
+        this.formInput = this.$refs.formInput as HTMLInputElement;
+        this.formReplyLink = this.$refs.formReplyLink as HTMLAnchorElement;
+        this.formReferenceLink = this.$refs.formReferenceLink as HTMLAnchorElement;
+        this.addCommentButton = this.$refs.addCommentButton;
+        this.hideFormButton = this.$refs.hideFormButton;
+        this.removeReplyToButton = this.$refs.removeReplyToButton;
+        this.removeReferenceButton = this.$refs.removeReferenceButton;
+
+        // WYSIWYG options
+        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
+        this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
+
+        // Translations
+        this.createdText = this.$opts.createdText;
+        this.countText = this.$opts.countText;
+        this.archivedCountText = this.$opts.archivedCountText;
+
+        this.formReplyText = this.formReplyLink?.textContent || '';
+
+        this.setupListeners();
+    }
+
+    protected setupListeners(): void {
+        this.elem.addEventListener('page-comment-delete', () => {
+            setTimeout(() => this.updateCount(), 1);
+            this.hideForm();
+        });
+
+        this.elem.addEventListener('page-comment-reply', ((event: CustomEvent<PageCommentReplyEventData>) => {
+            this.setReply(event.detail.id, event.detail.element);
+        }) as EventListener);
+
+        this.elem.addEventListener('page-comment-archive', ((event: CustomEvent<PageCommentArchiveEventData>) => {
+            this.archiveContainer.append(event.detail.new_thread_dom);
+            setTimeout(() => this.updateCount(), 1);
+        }) as EventListener);
+
+        this.elem.addEventListener('page-comment-unarchive', ((event: CustomEvent<PageCommentArchiveEventData>) => {
+            this.container.append(event.detail.new_thread_dom);
+            setTimeout(() => this.updateCount(), 1);
+        }) as EventListener);
+
+        if (this.form) {
+            this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
+            this.removeReferenceButton.addEventListener('click', () => this.setContentReference(''));
+            this.hideFormButton.addEventListener('click', this.hideForm.bind(this));
+            this.addCommentButton.addEventListener('click', this.showForm.bind(this));
+            this.form.addEventListener('submit', this.saveComment.bind(this));
+        }
+    }
+
+    protected saveComment(event: SubmitEvent): void {
+        event.preventDefault();
+        event.stopPropagation();
+
+        const loading = getLoading();
+        loading.classList.add('px-l');
+        this.form.after(loading);
+        this.form.toggleAttribute('hidden', true);
+
+        const reqData = {
+            html: this.wysiwygEditor.getContent(),
+            parent_id: this.parentId || null,
+            content_ref: this.contentReference,
+        };
+
+        window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
+            const newElem = htmlToDom(resp.data as string);
+
+            if (reqData.parent_id) {
+                this.formContainer.after(newElem);
+            } else {
+                this.container.append(newElem);
+            }
+
+            const refs = window.$components.allWithinElement<PageCommentReference>(newElem, 'page-comment-reference');
+            for (const ref of refs) {
+                ref.showForDisplay();
+            }
+
+            window.$events.success(this.createdText);
+            this.hideForm();
+            this.updateCount();
+        }).catch(err => {
+            this.form.toggleAttribute('hidden', false);
+            window.$events.showValidationErrors(err);
+        });
+
+        this.form.toggleAttribute('hidden', false);
+        loading.remove();
+    }
+
+    protected updateCount(): void {
+        const activeCount = this.getActiveThreadCount();
+        this.activeTab.textContent = window.$trans.choice(this.countText, activeCount);
+        const archivedCount = this.getArchivedThreadCount();
+        this.archivedTab.textContent = window.$trans.choice(this.archivedCountText, archivedCount);
+    }
+
+    protected resetForm(): void {
+        this.removeEditor();
+        this.formInput.value = '';
+        this.setContentReference('');
+        this.removeReplyTo();
+    }
+
+    protected showForm(): void {
+        this.removeEditor();
+        this.formContainer.toggleAttribute('hidden', false);
+        this.addButtonContainer.toggleAttribute('hidden', true);
+        this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+        this.loadEditor();
+
+        // Ensure the active comments tab is displaying
+        const tabs = window.$components.firstOnElement(this.elem, 'tabs');
+        if (tabs instanceof Tabs) {
+            tabs.show('comment-tab-panel-active');
+        }
+    }
+
+    protected hideForm(): void {
+        this.resetForm();
+        this.formContainer.toggleAttribute('hidden', true);
+        if (this.getActiveThreadCount() > 0) {
+            this.elem.append(this.addButtonContainer);
+        } else {
+            this.commentCountBar.append(this.addButtonContainer);
+        }
+        this.addButtonContainer.toggleAttribute('hidden', false);
+    }
+
+    protected loadEditor(): void {
+        if (this.wysiwygEditor) {
+            this.wysiwygEditor.focus();
+            return;
+        }
+
+        const config = buildForInput({
+            language: this.wysiwygLanguage,
+            containerElement: this.formInput,
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.wysiwygTextDirection,
+            drawioUrl: '',
+            pageId: 0,
+            translations: {},
+            translationMap: (window as unknown as Record<string, Object>).editor_translations,
+        });
+
+        (window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => {
+            this.wysiwygEditor = editors[0];
+            setTimeout(() => this.wysiwygEditor.focus(), 50);
+        });
+    }
+
+    protected removeEditor(): void {
+        if (this.wysiwygEditor) {
+            this.wysiwygEditor.remove();
+            this.wysiwygEditor = null;
+        }
+    }
+
+    protected getActiveThreadCount(): number {
+        return this.container.querySelectorAll(':scope > .comment-branch:not([hidden])').length;
+    }
+
+    protected getArchivedThreadCount(): number {
+        return this.archiveContainer.querySelectorAll(':scope > .comment-branch').length;
+    }
+
+    protected setReply(commentLocalId: string, commentElement: HTMLElement): void {
+        const targetFormLocation = (commentElement.closest('.comment-branch') as HTMLElement).querySelector('.comment-branch-children') as HTMLElement;
+        targetFormLocation.append(this.formContainer);
+        this.showForm();
+        this.parentId = Number(commentLocalId);
+        this.replyToRow.toggleAttribute('hidden', false);
+        this.formReplyLink.textContent = this.formReplyText.replace('1234', String(this.parentId));
+        this.formReplyLink.href = `#comment${this.parentId}`;
+    }
+
+    protected removeReplyTo(): void {
+        this.parentId = null;
+        this.replyToRow.toggleAttribute('hidden', true);
+        this.container.append(this.formContainer);
+        this.showForm();
+    }
+
+    public startNewComment(contentReference: string): void {
+        this.removeReplyTo();
+        this.setContentReference(contentReference);
+    }
+
+    protected setContentReference(reference: string): void {
+        this.contentReference = reference;
+        this.referenceRow.toggleAttribute('hidden', !Boolean(reference));
+        const [id] = reference.split(':');
+        this.formReferenceLink.href = `#${id}`;
+        this.formReferenceLink.onclick = function(event) {
+            event.preventDefault();
+            const el = document.getElementById(id);
+            if (el) {
+                scrollAndHighlightElement(el);
+            }
+        };
+    }
+
+}
similarity index 50%
rename from resources/js/components/pointer.js
rename to resources/js/components/pointer.ts
index 292b923e5519f47742990918214c7f46b7e9140b..4b927045aaedc4980101a30b1864d28c00f19037 100644 (file)
@@ -1,25 +1,39 @@
-import * as DOM from '../services/dom.ts';
+import * as DOM from '../services/dom';
 import {Component} from './component';
-import {copyTextToClipboard} from '../services/clipboard.ts';
+import {copyTextToClipboard} from '../services/clipboard';
+import {hashElement, normalizeNodeTextOffsetToParent} from "../services/dom";
+import {PageComments} from "./page-comments";
 
 export class Pointer extends Component {
 
+    protected showing: boolean = false;
+    protected isMakingSelection: boolean = false;
+    protected targetElement: HTMLElement|null = null;
+    protected targetSelectionRange: Range|null = null;
+
+    protected pointer!: HTMLElement;
+    protected linkInput!: HTMLInputElement;
+    protected linkButton!: HTMLElement;
+    protected includeInput!: HTMLInputElement;
+    protected includeButton!: HTMLElement;
+    protected sectionModeButton!: HTMLElement;
+    protected commentButton!: HTMLElement;
+    protected modeToggles!: HTMLElement[];
+    protected modeSections!: HTMLElement[];
+    protected pageId!: string;
+
     setup() {
-        this.container = this.$el;
         this.pointer = this.$refs.pointer;
-        this.linkInput = this.$refs.linkInput;
+        this.linkInput = this.$refs.linkInput as HTMLInputElement;
         this.linkButton = this.$refs.linkButton;
-        this.includeInput = this.$refs.includeInput;
+        this.includeInput = this.$refs.includeInput as HTMLInputElement;
         this.includeButton = this.$refs.includeButton;
         this.sectionModeButton = this.$refs.sectionModeButton;
+        this.commentButton = this.$refs.commentButton;
         this.modeToggles = this.$manyRefs.modeToggle;
         this.modeSections = this.$manyRefs.modeSection;
         this.pageId = this.$opts.pageId;
 
-        // Instance variables
-        this.showing = false;
-        this.isSelection = false;
-
         this.setupListeners();
     }
 
@@ -30,7 +44,7 @@ export class Pointer extends Component {
 
         // Select all contents on input click
         DOM.onSelect([this.includeInput, this.linkInput], event => {
-            event.target.select();
+            (event.target as HTMLInputElement).select();
             event.stopPropagation();
         });
 
@@ -41,7 +55,7 @@ export class Pointer extends Component {
 
         // Hide pointer when clicking away
         DOM.onEvents(document.body, ['click', 'focus'], () => {
-            if (!this.showing || this.isSelection) return;
+            if (!this.showing || this.isMakingSelection) return;
             this.hidePointer();
         });
 
@@ -52,9 +66,10 @@ export class Pointer extends Component {
         const pageContent = document.querySelector('.page-content');
         DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => {
             event.stopPropagation();
-            const targetEl = event.target.closest('[id^="bkmrk"]');
-            if (targetEl && window.getSelection().toString().length > 0) {
-                this.showPointerAtTarget(targetEl, event.pageX, false);
+            const targetEl = (event.target as HTMLElement).closest('[id^="bkmrk"]');
+            if (targetEl instanceof HTMLElement && (window.getSelection() || '').toString().length > 0) {
+                const xPos = (event instanceof MouseEvent) ? event.pageX : 0;
+                this.showPointerAtTarget(targetEl, xPos, false);
             }
         });
 
@@ -63,28 +78,35 @@ export class Pointer extends Component {
 
         // Toggle between pointer modes
         DOM.onSelect(this.modeToggles, event => {
+            const targetToggle = (event.target as HTMLElement);
             for (const section of this.modeSections) {
-                const show = !section.contains(event.target);
+                const show = !section.contains(targetToggle);
                 section.toggleAttribute('hidden', !show);
             }
 
-            this.modeToggles.find(b => b !== event.target).focus();
+            const otherToggle = this.modeToggles.find(b => b !== targetToggle);
+            otherToggle && otherToggle.focus();
         });
+
+        if (this.commentButton) {
+            DOM.onSelect(this.commentButton, this.createCommentAtPointer.bind(this));
+        }
     }
 
     hidePointer() {
-        this.pointer.style.display = null;
+        this.pointer.style.removeProperty('display');
         this.showing = false;
+        this.targetElement = null;
+        this.targetSelectionRange = null;
     }
 
     /**
      * Move and display the pointer at the given element, targeting the given screen x-position if possible.
-     * @param {Element} element
-     * @param {Number} xPosition
-     * @param {Boolean} keyboardMode
      */
-    showPointerAtTarget(element, xPosition, keyboardMode) {
-        this.updateForTarget(element);
+    showPointerAtTarget(element: HTMLElement, xPosition: number, keyboardMode: boolean) {
+        this.targetElement = element;
+        this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null;
+        this.updateDomForTarget(element);
 
         this.pointer.style.display = 'block';
         const targetBounds = element.getBoundingClientRect();
@@ -98,18 +120,18 @@ export class Pointer extends Component {
         this.pointer.style.top = `${yOffset}px`;
 
         this.showing = true;
-        this.isSelection = true;
+        this.isMakingSelection = true;
 
         setTimeout(() => {
-            this.isSelection = false;
+            this.isMakingSelection = false;
         }, 100);
 
         const scrollListener = () => {
             this.hidePointer();
-            window.removeEventListener('scroll', scrollListener, {passive: true});
+            window.removeEventListener('scroll', scrollListener);
         };
 
-        element.parentElement.insertBefore(this.pointer, element);
+        element.parentElement?.insertBefore(this.pointer, element);
         if (!keyboardMode) {
             window.addEventListener('scroll', scrollListener, {passive: true});
         }
@@ -117,9 +139,8 @@ export class Pointer extends Component {
 
     /**
      * Update the pointer inputs/content for the given target element.
-     * @param {?Element} element
      */
-    updateForTarget(element) {
+    updateDomForTarget(element: HTMLElement) {
         const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
         const includeTag = `{{@${this.pageId}#${element.id}}}`;
 
@@ -128,18 +149,18 @@ export class Pointer extends Component {
 
         // Update anchor if present
         const editAnchor = this.pointer.querySelector('#pointer-edit');
-        if (editAnchor && element) {
+        if (editAnchor instanceof HTMLAnchorElement && element) {
             const {editHref} = editAnchor.dataset;
             const elementId = element.id;
 
             // Get the first 50 characters.
-            const queryContent = element.textContent && element.textContent.substring(0, 50);
+            const queryContent = (element.textContent || '').substring(0, 50);
             editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
         }
     }
 
     enterSectionSelectMode() {
-        const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]'));
+        const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]')) as HTMLElement[];
         for (const section of sections) {
             section.setAttribute('tabindex', '0');
         }
@@ -147,9 +168,39 @@ export class Pointer extends Component {
         sections[0].focus();
 
         DOM.onEnterPress(sections, event => {
-            this.showPointerAtTarget(event.target, 0, true);
+            this.showPointerAtTarget(event.target as HTMLElement, 0, true);
             this.pointer.focus();
         });
     }
 
+    createCommentAtPointer() {
+        if (!this.targetElement) {
+            return;
+        }
+
+        const refId = this.targetElement.id;
+        const hash = hashElement(this.targetElement);
+        let range = '';
+        if (this.targetSelectionRange) {
+            const commonContainer = this.targetSelectionRange.commonAncestorContainer;
+            if (this.targetElement.contains(commonContainer)) {
+                const start = normalizeNodeTextOffsetToParent(
+                    this.targetSelectionRange.startContainer,
+                    this.targetSelectionRange.startOffset,
+                    this.targetElement
+                );
+                const end = normalizeNodeTextOffsetToParent(
+                    this.targetSelectionRange.endContainer,
+                    this.targetSelectionRange.endOffset,
+                    this.targetElement
+                );
+                range = `${start}-${end}`;
+            }
+        }
+
+        const reference = `${refId}:${hash}:${range}`;
+        const pageComments = window.$components.first('page-comments') as PageComments;
+        pageComments.startNewComment(reference);
+    }
+
 }
similarity index 74%
rename from resources/js/components/tabs.js
rename to resources/js/components/tabs.ts
index f0fc058ced7fd8377123f831ac93c631d27d4952..a03d37cd48c9f36ee38c55b9eb52345d9503c0d6 100644 (file)
@@ -1,5 +1,9 @@
 import {Component} from './component';
 
+export interface TabsChangeEvent {
+    showing: string;
+}
+
 /**
  * Tabs
  * Uses accessible attributes to drive its functionality.
@@ -19,18 +23,25 @@ import {Component} from './component';
  */
 export class Tabs extends Component {
 
+    protected container!: HTMLElement;
+    protected tabList!: HTMLElement;
+    protected tabs!: HTMLElement[];
+    protected panels!: HTMLElement[];
+
+    protected activeUnder!: number;
+    protected active: null|boolean = null;
+
     setup() {
         this.container = this.$el;
-        this.tabList = this.container.querySelector('[role="tablist"]');
+        this.tabList = this.container.querySelector('[role="tablist"]') as HTMLElement;
         this.tabs = Array.from(this.tabList.querySelectorAll('[role="tab"]'));
         this.panels = Array.from(this.container.querySelectorAll(':scope > [role="tabpanel"], :scope > * > [role="tabpanel"]'));
         this.activeUnder = this.$opts.activeUnder ? Number(this.$opts.activeUnder) : 10000;
-        this.active = null;
 
         this.container.addEventListener('click', event => {
-            const tab = event.target.closest('[role="tab"]');
-            if (tab && this.tabs.includes(tab)) {
-                this.show(tab.getAttribute('aria-controls'));
+            const tab = (event.target as HTMLElement).closest('[role="tab"]');
+            if (tab instanceof HTMLElement && this.tabs.includes(tab)) {
+                this.show(tab.getAttribute('aria-controls') || '');
             }
         });
 
@@ -40,7 +51,7 @@ export class Tabs extends Component {
         this.updateActiveState();
     }
 
-    show(sectionId) {
+    public show(sectionId: string): void {
         for (const panel of this.panels) {
             panel.toggleAttribute('hidden', panel.id !== sectionId);
         }
@@ -51,10 +62,11 @@ export class Tabs extends Component {
             tab.setAttribute('aria-selected', selected ? 'true' : 'false');
         }
 
-        this.$emit('change', {showing: sectionId});
+        const data: TabsChangeEvent = {showing: sectionId};
+        this.$emit('change', data);
     }
 
-    updateActiveState() {
+    protected updateActiveState(): void {
         const active = window.innerWidth < this.activeUnder;
         if (active === this.active) {
             return;
@@ -69,13 +81,13 @@ export class Tabs extends Component {
         this.active = active;
     }
 
-    activate() {
+    protected activate(): void {
         const panelToShow = this.panels.find(p => !p.hasAttribute('hidden')) || this.panels[0];
         this.show(panelToShow.id);
         this.tabList.toggleAttribute('hidden', false);
     }
 
-    deactivate() {
+    protected deactivate(): void {
         for (const panel of this.panels) {
             panel.removeAttribute('hidden');
         }
index 043f1745ff675e3f81d665e0b1f185cb1ae0d0a0..5014051ab0434274ce5bf564f57058d8a0a75faa 100644 (file)
@@ -58,6 +58,11 @@ describe('Translations Service', () => {
             expect(caseB).toEqual('an orange angry big dinosaur');
         });
 
+        test('it provides count as a replacement by default', () => {
+            const caseA = $trans.choice(`:count cats|:count dogs`, 4);
+            expect(caseA).toEqual('4 dogs');
+        });
+
         test('not provided replacements are left as-is', () => {
             const caseA = $trans.choice(`An :a dog`, 5, {});
             expect(caseA).toEqual('An :a dog');
index c19939e92a9b874aa61da08943012c6cbd9aa505..0e13cd0a09fca78429a117a50b2aa875f18012e3 100644 (file)
@@ -139,8 +139,8 @@ export class ComponentStore {
     /**
      * Get all the components of the given name.
      */
-    public get(name: string): Component[] {
-        return this.components[name] || [];
+    public get<T extends Component>(name: string): T[] {
+        return (this.components[name] || []) as T[];
     }
 
     /**
@@ -150,4 +150,9 @@ export class ComponentStore {
         const elComponents = this.elementComponentMap.get(element) || {};
         return elComponents[name] || null;
     }
+
+    public allWithinElement<T extends Component>(element: HTMLElement, name: string): T[] {
+        const components = this.get<T>(name);
+        return components.filter(c => element.contains(c.$el));
+    }
 }
index c88827bac40a1788b89295152799b62e9871425f..c3817536c85422c8d0e480cbd05f267be3f6633f 100644 (file)
@@ -1,3 +1,5 @@
+import {cyrb53} from "./util";
+
 /**
  * Check if the given param is a HTMLElement
  */
@@ -44,9 +46,11 @@ export function forEach(selector: string, callback: (el: Element) => any) {
 /**
  * Helper to listen to multiple DOM events
  */
-export function onEvents(listenerElement: Element, events: string[], callback: (e: Event) => any): void {
-    for (const eventName of events) {
-        listenerElement.addEventListener(eventName, callback);
+export function onEvents(listenerElement: Element|null, events: string[], callback: (e: Event) => any): void {
+    if (listenerElement) {
+        for (const eventName of events) {
+            listenerElement.addEventListener(eventName, callback);
+        }
     }
 }
 
@@ -178,3 +182,78 @@ export function htmlToDom(html: string): HTMLElement {
 
     return firstChild;
 }
+
+/**
+ * For the given node and offset, return an adjusted offset that's relative to the given parent element.
+ */
+export function normalizeNodeTextOffsetToParent(node: Node, offset: number, parentElement: HTMLElement): number {
+    if (!parentElement.contains(node)) {
+        throw new Error('ParentElement must be a prent of element');
+    }
+
+    let normalizedOffset = offset;
+    let currentNode: Node|null = node.nodeType === Node.TEXT_NODE ?
+        node : node.childNodes[offset];
+
+    while (currentNode !== parentElement && currentNode) {
+        if (currentNode.previousSibling) {
+            currentNode = currentNode.previousSibling;
+            normalizedOffset += (currentNode.textContent?.length || 0);
+        } else {
+            currentNode = currentNode.parentNode;
+        }
+    }
+
+    return normalizedOffset;
+}
+
+/**
+ * Find the target child node and adjusted offset based on a parent node and text offset.
+ * Returns null if offset not found within the given parent node.
+ */
+export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number): ({node: Node, offset: number}|null) {
+    if (offset === 0) {
+        return { node: parentNode, offset: 0 };
+    }
+
+    let currentOffset = 0;
+    let currentNode = null;
+
+    for (let i = 0; i < parentNode.childNodes.length; i++) {
+        currentNode = parentNode.childNodes[i];
+
+        if (currentNode.nodeType === Node.TEXT_NODE) {
+            // For text nodes, count the length of their content
+            // Returns if within range
+            const textLength = (currentNode.textContent || '').length;
+            if (currentOffset + textLength >= offset) {
+                return {
+                    node: currentNode,
+                    offset: offset - currentOffset
+                };
+            }
+
+            currentOffset += textLength;
+        } else if (currentNode.nodeType === Node.ELEMENT_NODE) {
+            // Otherwise, if an element, track the text length and search within
+            // if in range for the target offset
+            const elementTextLength = (currentNode.textContent || '').length;
+            if (currentOffset + elementTextLength >= offset) {
+                return findTargetNodeAndOffset(currentNode as HTMLElement, offset - currentOffset);
+            }
+
+            currentOffset += elementTextLength;
+        }
+    }
+
+    // Return null if not found within range
+    return null;
+}
+
+/**
+ * Create a hash for the given HTML element content.
+ */
+export function hashElement(element: HTMLElement): string {
+    const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, '');
+    return cyrb53(normalisedElemText);
+}
\ No newline at end of file
index be9fba7eca515cebd616c09acfd1995f916f65ec..6045d51f823846fae39803befb9efb1dc0d7cca3 100644 (file)
@@ -1,7 +1,9 @@
 import {HttpError} from "./http";
 
+type Listener = (data: any) => void;
+
 export class EventManager {
-    protected listeners: Record<string, ((data: any) => void)[]> = {};
+    protected listeners: Record<string, Listener[]> = {};
     protected stack: {name: string, data: {}}[] = [];
 
     /**
@@ -24,6 +26,17 @@ export class EventManager {
         this.listeners[eventName].push(callback);
     }
 
+    /**
+     * Remove an event listener which is using the given callback for the given event name.
+     */
+    remove(eventName: string, callback: Listener): void {
+        const listeners = this.listeners[eventName] || [];
+        const index = listeners.indexOf(callback);
+        if (index !== -1) {
+            listeners.splice(index, 1);
+        }
+    }
+
     /**
      * Emit an event for public use.
      * Sends the event via the native DOM event handling system.
@@ -53,8 +66,7 @@ export class EventManager {
     /**
      * Notify of standard server-provided validation errors.
      */
-    showValidationErrors(responseErr: {status?: number, data?: object}): void {
-        if (!responseErr.status) return;
+    showValidationErrors(responseErr: HttpError): void {
         if (responseErr.status === 422 && responseErr.data) {
             const message = Object.values(responseErr.data).flat().join('\n');
             this.error(message);
index b37dbdfb07438501a26feca177b1f5d78b6ba3e9..f548a51d1d17d4094d6f2e51fb204989e4d5111d 100644 (file)
@@ -10,6 +10,7 @@ export class Translator {
      * to use. Similar format at Laravel's 'trans_choice' helper.
      */
     choice(translation: string, count: number, replacements: Record<string, string> = {}): string {
+        replacements = Object.assign({}, {count: String(count)}, replacements);
         const splitText = translation.split('|');
         const exactCountRegex = /^{([0-9]+)}/;
         const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
index c5a5d2db804915153b96d197ea81df808916f1ea..61a02a3d24de1d97afcee28e22005b89b7bfef60 100644 (file)
@@ -144,4 +144,25 @@ function getVersion(): string {
 export function importVersioned(moduleName: string): Promise<object> {
     const importPath = window.baseUrl(`dist/${moduleName}.js?version=${getVersion()}`);
     return import(importPath);
+}
+
+/*
+    cyrb53 (c) 2018 bryc (github.com/bryc)
+    License: Public domain (or MIT if needed). Attribution appreciated.
+    A fast and simple 53-bit string hash function with decent collision resistance.
+    Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
+    Taken from: https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
+*/
+export function cyrb53(str: string, seed: number = 0): string {
+    let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
+    for(let i = 0, ch; i < str.length; i++) {
+        ch = str.charCodeAt(i);
+        h1 = Math.imul(h1 ^ ch, 2654435761);
+        h2 = Math.imul(h2 ^ ch, 1597334677);
+    }
+    h1  = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
+    h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
+    h2  = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
+    h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
+    return String((4294967296 * (2097151 & h2) + (h1 >>> 0)));
 }
\ No newline at end of file
index f1aa3139b8ea7aff9f01088d8a680584b483e9c1..ccbe36161b67c0a4d0781b16283fc6d50f0f94ca 100644 (file)
   animation-duration: 180ms;
   animation-delay: 0s;
   animation-timing-function: cubic-bezier(.62, .28, .23, .99);
+}
+
+@keyframes highlight {
+  0% {
+    background-color: var(--color-primary-light);
+  }
+  33% {
+    background-color: transparent;
+  }
+  66% {
+    background-color: var(--color-primary-light);
+  }
+  100% {
+    background-color: transparent;
+  }
+}
+
+.anim-highlight {
+  animation-name: highlight;
+  animation-duration: 2s;
+  animation-delay: 0s;
+  animation-timing-function: linear;
 }
\ No newline at end of file
index 58d39d3ee6e0e9c3f7e29d98bc17269fd6816ab7..9e96b39fbb4834ef75e8b19f787268093513ec46 100644 (file)
@@ -569,6 +569,9 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   border-bottom: 0;
   padding: 0 vars.$xs;
 }
+.tab-container [role="tabpanel"].no-outline:focus {
+  outline: none;
+}
 
 .image-picker .none {
   display: none;
@@ -746,6 +749,52 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   height: calc(100% - vars.$m);
 }
 
+.comment-reference-indicator-wrap a {
+  float: left;
+  margin-top: vars.$xs;
+  font-size: 12px;
+  display: inline-block;
+  font-weight: bold;
+  position: relative;
+  border-radius: 4px;
+  overflow: hidden;
+  padding: 2px 6px 2px 0;
+  margin-inline-end: vars.$xs;
+  color: var(--color-link);
+  span {
+    display: none;
+  }
+  &.outdated span {
+    display: inline;
+  }
+  &.outdated.missing {
+    color: var(--color-warning);
+    pointer-events: none;
+  }
+  svg {
+    width: 24px;
+    margin-inline-end: 0;
+  }
+  &:after {
+    background-color: currentColor;
+    content: '';
+    width: 100%;
+    height: 100%;
+    position: absolute;
+    left: 0;
+    top: 0;
+    opacity: 0.15;
+  }
+  &[href="#"] {
+    color: #444;
+    pointer-events: none;
+  }
+}
+
+.comment-branch .comment-box {
+  margin-bottom: vars.$m;
+}
+
 .comment-branch .comment-branch .comment-branch .comment-branch .comment-thread-indicator {
   display: none;
 }
@@ -760,7 +809,15 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   display: block;
 }
 
+.comment-container .empty-state {
+  display: none;
+}
+.comment-container:not(:has([component="page-comment"])) .empty-state {
+  display: block;
+}
+
 .comment-container-compact .comment-box {
+  margin-bottom: vars.$xs;
   .meta {
     font-size: 0.8rem;
   }
@@ -778,6 +835,29 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   width: vars.$m;
 }
 
+.comment-container-super-compact .comment-box {
+  .meta {
+    font-size: 12px;
+  }
+  .avatar {
+    width: 22px;
+    height: 22px;
+    margin-inline-end: 2px !important;
+  }
+  .content {
+    padding: vars.$xxs vars.$s;
+    line-height: 1.2;
+  }
+  .content p {
+    font-size: 12px;
+  }
+}
+
+.comment-container-super-compact .comment-thread-indicator {
+  width: (vars.$xs + 3px);
+  margin-inline-start: 3px;
+}
+
 #tag-manager .drag-card {
   max-width: 500px;
 }
@@ -1127,4 +1207,21 @@ input.scroll-box-search, .scroll-box-header-item {
 }
 .scroll-box > li.empty-state:last-child {
   display: list-item;
+}
+
+details.section-expander summary {
+  border-top: 1px solid #DDD;
+  @include mixins.lightDark(border-color, #DDD, #000);
+  font-weight: bold;
+  font-size: 12px;
+  color: #888;
+  cursor: pointer;
+  padding-block: vars.$xs;
+}
+details.section-expander:open summary {
+  margin-bottom: vars.$s;
+}
+details.section-expander {
+  border-bottom: 1px solid #DDD;
+  @include mixins.lightDark(border-color, #DDD, #000);
 }
\ No newline at end of file
index b0176d64ef1284a8de4e7cd0db32a4cad3ff4b3f..aba1556a9836e49ffbf2e38a1190115084e55fc6 100644 (file)
@@ -11,6 +11,7 @@
   max-width: 840px;
   margin: 0 auto;
   overflow-wrap: break-word;
+  position: relative;
   .align-left {
     text-align: left;
   }
index 45e58ffc865030098c45067e01a876778d896a99..83aec46f0935c62824d35e3f8c48bb2493f86206 100755 (executable)
@@ -158,11 +158,7 @@ body.tox-fullscreen, body.markdown-fullscreen {
   border-radius: 4px;
   box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.1);
   @include mixins.lightDark(background-color, #fff, #333);
-  width: 275px;
-
-  &.is-page-editable {
-    width: 328px;
-  }
+  width: 328px;
 
   &:before {
     position: absolute;
@@ -183,7 +179,6 @@ body.tox-fullscreen, body.markdown-fullscreen {
   }
   input, button, a {
     position: relative;
-    border-radius: 0;
     height: 28px;
     font-size: 12px;
     vertical-align: top;
@@ -194,17 +189,21 @@ body.tox-fullscreen, body.markdown-fullscreen {
     border: 1px solid #DDD;
     @include mixins.lightDark(border-color, #ddd, #000);
     color: #666;
-    width: 160px;
-    z-index: 40;
-    padding: 5px 10px;
+    width: auto;
+    flex: 1;
+    z-index: 58;
+    padding: 5px;
+    border-radius: 0;
   }
   .text-button {
     @include mixins.lightDark(color, #444, #AAA);
   }
   .input-group .button {
     line-height: 1;
-    margin: 0 0 0 -4px;
+    margin-inline-start: -1px;
+    margin-block: 0;
     box-shadow: none;
+    border-radius: 0;
   }
   a.button {
     margin: 0;
@@ -218,6 +217,97 @@ body.tox-fullscreen, body.markdown-fullscreen {
   }
 }
 
+// Page inline comments
+.content-comment-highlight {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 0;
+  height: 0;
+  user-select: none;
+  pointer-events: none;
+  &:after {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    background-color: var(--color-primary);
+    opacity: 0.25;
+  }
+}
+.content-comment-window {
+  font-size: vars.$fs-m;
+  line-height: 1.4;
+  position: absolute;
+  top: calc(100% + 3px);
+  left: 0;
+  z-index: 92;
+  pointer-events: all;
+  min-width: min(340px, 80vw);
+  @include mixins.lightDark(background-color, #FFF, #222);
+  box-shadow: vars.$bs-hover;
+  border-radius: 4px;
+  overflow: hidden;
+}
+.content-comment-window-actions {
+  background-color: var(--color-primary);
+  color: #FFF;
+  display: flex;
+  align-items: center;
+  justify-content: end;
+  gap: vars.$xs;
+  button {
+    color: #FFF;
+    font-size: 12px;
+    padding: vars.$xs;
+    line-height: 1;
+    cursor: pointer;
+  }
+  button[data-action="jump"] {
+    text-decoration: underline;
+  }
+  svg {
+    fill: currentColor;
+    width: 12px;
+  }
+}
+.content-comment-window-content {
+  padding: vars.$xs vars.$s vars.$xs vars.$xs;
+  max-height: 200px;
+  overflow-y: scroll;
+}
+.content-comment-window-content .comment-reference-indicator-wrap {
+  display: none;
+}
+.content-comment-marker {
+  position: absolute;
+  right: -16px;
+  top: -16px;
+  pointer-events: all;
+  width: min(1.5em, 32px);
+  height: min(1.5em, 32px);
+  border-radius: min(calc(1.5em / 2), 32px);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: var(--color-primary);
+  box-shadow: vars.$bs-hover;
+  color: #FFF;
+  cursor: pointer;
+  z-index: 90;
+  transform: scale(1);
+  transition: transform ease-in-out 120ms;
+  svg {
+    fill: #FFF;
+    width: 80%;
+  }
+}
+.page-content [id^="bkmrk-"]:hover .content-comment-marker {
+  transform: scale(1.15);
+}
+
 // Page editor sidebar toolbox
 .floating-toolbox {
   @include mixins.lightDark(background-color, #FFF, #222);
index 78d19ac3ea41393953a415d02d18eb94dcc21774..658c33219c33886a585430084987999b4804f8dc 100644 (file)
@@ -1,13 +1,16 @@
+{{--
+$branch CommentTreeNode
+--}}
 <div class="comment-branch">
-    <div class="mb-m">
-        @include('comments.comment', ['comment' => $branch['comment']])
+    <div>
+        @include('comments.comment', ['comment' => $branch->comment])
     </div>
     <div class="flex-container-row">
         <div class="comment-thread-indicator-parent">
             <div class="comment-thread-indicator"></div>
         </div>
         <div class="comment-branch-children flex">
-            @foreach($branch['children'] as $childBranch)
+            @foreach($branch->children as $childBranch)
                 @include('comments.comment-branch', ['branch' => $childBranch])
             @endforeach
         </div>
index 2bf89d6832df70175a2d899079513de93935d43f..eadf3518722c0a6ff29ba90f8cb711cd1a6f903b 100644 (file)
@@ -4,9 +4,9 @@
 <div component="{{ $readOnly ? '' : 'page-comment' }}"
      option:page-comment:comment-id="{{ $comment->id }}"
      option:page-comment:comment-local-id="{{ $comment->local_id }}"
-     option:page-comment:comment-parent-id="{{ $comment->parent_id }}"
      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}}"
                     @if(userCan('comment-create-all'))
                         <button refs="page-comment@reply-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('reply') {{ trans('common.reply') }}</button>
                     @endif
+                    @if(!$comment->parent_id && (userCan('comment-update', $comment) || userCan('comment-delete', $comment)))
+                        <button refs="page-comment@archive-button"
+                                type="button"
+                                data-is-archived="{{ $comment->archived ? 'true' : 'false' }}"
+                                class="text-button text-muted hover-underline text-small p-xs">@icon('archive') {{ trans('common.' . ($comment->archived ? 'unarchive' : 'archive')) }}</button>
+                    @endif
                     @if(userCan('comment-update', $comment))
                         <button refs="page-comment@edit-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('edit') {{ trans('common.edit') }}</button>
                     @endif
                 <a class="text-muted text-small" href="#comment{{ $comment->parent_id }}">@icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }}</a>
             </p>
         @endif
+        @if($comment->content_ref)
+            <div class="comment-reference-indicator-wrap">
+                <a component="page-comment-reference"
+                   option:page-comment-reference:reference="{{ $comment->content_ref }}"
+                   option:page-comment-reference:view-comment-text="{{ trans('entities.comment_view') }}"
+                   option:page-comment-reference:jump-to-thread-text="{{ trans('entities.comment_jump_to_thread') }}"
+                   option:page-comment-reference:close-text="{{ trans('common.close') }}"
+                   href="#">@icon('bookmark'){{ trans('entities.comment_reference') }} <span>{{ trans('entities.comment_reference_outdated') }}</span></a>
+            </div>
+        @endif
         {!! $commentHtml  !!}
     </div>
 
index 48bf885fff5e6ccba76c75de9c3e558a4ef1d23f..51c08d69a851f8c80238eeb324fc9ebfacfc72ae 100644 (file)
@@ -1,40 +1,75 @@
-<section component="page-comments"
+<section components="page-comments tabs"
          option:page-comments:page-id="{{ $page->id }}"
          option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
-         option:page-comments:count-text="{{ trans('entities.comment_count') }}"
+         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"
+         class="comments-list tab-container"
          aria-label="{{ trans('entities.comments') }}">
 
-    <div refs="page-comments@comment-count-bar" class="grid half left-focus v-center no-row-gap">
-        <h5 refs="page-comments@comments-title">{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}</h5>
+    <div refs="page-comments@comment-count-bar" class="flex-container-row items-center">
+        <div role="tablist" class="flex">
+            <button type="button"
+                    role="tab"
+                    id="comment-tab-active"
+                    aria-controls="comment-tab-panel-active"
+                    refs="page-comments@active-tab"
+                    aria-selected="true">{{ trans_choice('entities.comment_thread_count', $commentTree->activeThreadCount()) }}</button>
+            <button type="button"
+                    role="tab"
+                    id="comment-tab-archived"
+                    aria-controls="comment-tab-panel-archived"
+                    refs="page-comments@archived-tab"
+                    aria-selected="false">{{ trans_choice('entities.comment_archived_count', count($commentTree->getArchived())) }}</button>
+        </div>
         @if ($commentTree->empty() && userCan('comment-create-all'))
-            <div class="text-m-right" refs="page-comments@add-button-container">
+            <div class="ml-m" refs="page-comments@add-button-container">
                 <button type="button"
                         refs="page-comments@add-comment-button"
-                        class="button outline">{{ trans('entities.comment_add') }}</button>
+                        class="button outline mb-m">{{ trans('entities.comment_add') }}</button>
             </div>
         @endif
     </div>
 
-    <div refs="page-comments@commentContainer" class="comment-container">
-        @foreach($commentTree->get() as $branch)
+    <div id="comment-tab-panel-active"
+         tabindex="0"
+         role="tabpanel"
+         aria-labelledby="comment-tab-active"
+         class="comment-container no-outline">
+        <div refs="page-comments@comment-container">
+            @foreach($commentTree->getActive() as $branch)
+                @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false])
+            @endforeach
+        </div>
+
+        <p class="text-center text-muted italic empty-state">{{ trans('entities.comment_none') }}</p>
+
+        @if(userCan('comment-create-all'))
+            @include('comments.create')
+            @if (!$commentTree->empty())
+                <div refs="page-comments@addButtonContainer" class="flex-container-row">
+                    <button type="button"
+                            refs="page-comments@add-comment-button"
+                            class="button outline ml-auto">{{ trans('entities.comment_add') }}</button>
+                </div>
+            @endif
+        @endif
+    </div>
+
+    <div refs="page-comments@archive-container"
+         id="comment-tab-panel-archived"
+         tabindex="0"
+         role="tabpanel"
+         aria-labelledby="comment-tab-archived"
+         hidden="hidden"
+         class="comment-container no-outline">
+        @foreach($commentTree->getArchived() as $branch)
             @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false])
         @endforeach
+            <p class="text-center text-muted italic empty-state">{{ trans('entities.comment_none') }}</p>
     </div>
 
-    @if(userCan('comment-create-all'))
-        @include('comments.create')
-        @if (!$commentTree->empty())
-            <div refs="page-comments@addButtonContainer" class="text-right">
-                <button type="button"
-                        refs="page-comments@add-comment-button"
-                        class="button outline">{{ trans('entities.comment_add') }}</button>
-            </div>
-        @endif
-    @endif
-
     @if(userCan('comment-create-all') || $commentTree->canUpdateAny())
         @push('body-end')
             <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}" defer></script>
index 417f0c6060244f5c187162caab7b8e42187655ea..134ed51642547dfcd3af1ee8e610159dae4a53a4 100644 (file)
                 </div>
             </div>
         </div>
+        <div refs="page-comments@reference-row" hidden class="primary-background-light text-muted px-s py-xs">
+            <div class="grid left-focus v-center">
+                <div>
+                    <a refs="page-comments@formReferenceLink" href="#">{{ trans('entities.comment_reference') }}</a>
+                </div>
+                <div class="text-right">
+                    <button refs="page-comments@remove-reference-button" class="text-button">{{ trans('common.remove') }}</button>
+                </div>
+            </div>
+        </div>
 
         <div class="content px-s pt-s">
             <form refs="page-comments@form" novalidate>
index 56f36cb75f3d91b48d446a86ad8715f4f36a7bab..f6487b66600d4c6051b68ec99d38912fa99fedad 100644 (file)
@@ -6,29 +6,36 @@
          tabindex="-1"
          aria-label="{{ trans('entities.pages_pointer_label') }}"
          class="pointer-container">
-        <div class="pointer flex-container-row items-center justify-space-between p-s anim {{ userCan('page-update', $page) ? 'is-page-editable' : ''}}" >
-            <div refs="pointer@mode-section" class="flex-container-row items-center gap-s">
+        <div class="pointer flex-container-row items-center justify-space-between gap-xs p-xs anim" >
+            <div refs="pointer@mode-section" class="flex flex-container-row items-center gap-xs">
                 <button refs="pointer@mode-toggle"
                         title="{{ trans('entities.pages_pointer_toggle_link') }}"
                         class="text-button icon px-xs">@icon('link')</button>
-                <div class="input-group">
+                <div class="input-group flex flex-container-row items-center">
                     <input refs="pointer@link-input" aria-label="{{ trans('entities.pages_pointer_permalink') }}" readonly="readonly" type="text" id="pointer-url" placeholder="url">
-                    <button refs="pointer@link-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+                    <button refs="pointer@link-button" class="button outline icon px-xs" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
                 </div>
             </div>
-            <div refs="pointer@mode-section" hidden class="flex-container-row items-center gap-s">
+            <div refs="pointer@mode-section" hidden class="flex flex-container-row items-center gap-xs">
                 <button refs="pointer@mode-toggle"
                         title="{{ trans('entities.pages_pointer_toggle_include') }}"
                         class="text-button icon px-xs">@icon('include')</button>
-                <div class="input-group">
+                <div class="input-group flex flex-container-row items-center">
                     <input refs="pointer@include-input" aria-label="{{ trans('entities.pages_pointer_include_tag') }}" readonly="readonly" type="text" id="pointer-include" placeholder="include">
-                    <button refs="pointer@include-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+                    <button refs="pointer@include-button" class="button outline icon px-xs" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
                 </div>
             </div>
-            @if(userCan('page-update', $page))
-                <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}"
-                   class="button primary outline icon heading-edit-icon ml-s px-s" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a>
-            @endif
+            <div>
+                @if(userCan('page-update', $page))
+                    <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}"
+                       class="button primary outline icon heading-edit-icon px-xs" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a>
+                @endif
+                @if($commentTree->enabled() && userCan('comment-create-all'))
+                    <button type="button"
+                            refs="pointer@comment-button"
+                            class="button primary outline icon px-xs m-none" title="{{ trans('entities.comment_add')}}">@icon('comment')</button>
+                @endif
+            </div>
         </div>
     </div>
 
index d632b85c68997d3cc9d18fe9f11034e51e3e73ae..72958a2fede3d6050ad5a77fc756644456562dce 100644 (file)
@@ -1,3 +1,6 @@
+{{--
+$comments - CommentTree
+--}}
 <div refs="editor-toolbox@tab-content" data-tab-content="comments" class="toolbox-tab-content">
     <h4>{{ trans('entities.comments') }}</h4>
 
@@ -5,11 +8,19 @@
         <p class="text-muted small mb-m">
             {{ trans('entities.comment_editor_explain') }}
         </p>
-        @foreach($comments->get() as $branch)
+        @foreach($comments->getActive() as $branch)
             @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true])
         @endforeach
         @if($comments->empty())
-            <p class="italic text-muted">{{ trans('common.no_items') }}</p>
+            <p class="italic text-muted">{{ trans('entities.comment_none') }}</p>
+        @endif
+        @if($comments->archivedThreadCount() > 0)
+            <details class="section-expander mt-s">
+                <summary>{{ trans('entities.comment_archived_threads') }}</summary>
+                @foreach($comments->getArchived() as $branch)
+                    @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true])
+                @endforeach
+            </details>
         @endif
     </div>
 </div>
\ No newline at end of file
index e3a31dd5ebf1bcd18b6ce0977565ed6ef7db5d11..137d43bdb1af376794963266d216864e8e92633b 100644 (file)
     @include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous])
 
     @if ($commentTree->enabled())
-        @if(($previous || $next))
-            <div class="px-xl print-hidden">
-                <hr class="darker">
-            </div>
-        @endif
-
         <div class="comments-container mb-l print-hidden">
             @include('comments.comments', ['commentTree' => $commentTree, 'page' => $page])
             <div class="clearfix"></div>
index 8184725834caae44f3dae98942d2bc981ec84406..ea3efe1ac776cea1d6ebf16349531d3958b6cd8b 100644 (file)
@@ -179,6 +179,8 @@ Route::middleware('auth')->group(function () {
 
     // Comments
     Route::post('/comment/{pageId}', [ActivityControllers\CommentController::class, 'savePageComment']);
+    Route::put('/comment/{id}/archive', [ActivityControllers\CommentController::class, 'archive']);
+    Route::put('/comment/{id}/unarchive', [ActivityControllers\CommentController::class, 'unarchive']);
     Route::put('/comment/{id}', [ActivityControllers\CommentController::class, 'update']);
     Route::delete('/comment/{id}', [ActivityControllers\CommentController::class, 'destroy']);
 
diff --git a/tests/Entity/CommentDisplayTest.php b/tests/Entity/CommentDisplayTest.php
new file mode 100644 (file)
index 0000000..4e9640b
--- /dev/null
@@ -0,0 +1,134 @@
+<?php
+
+namespace Entity;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Activity\Models\Comment;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class CommentDisplayTest extends TestCase
+{
+    public function test_reply_comments_are_nested()
+    {
+        $this->asAdmin();
+        $page = $this->entities->page();
+
+        $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
+        $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
+
+        $respHtml = $this->withHtml($this->get($page->getUrl()));
+        $respHtml->assertElementCount('.comment-branch', 3);
+        $respHtml->assertElementNotExists('.comment-branch .comment-branch');
+
+        $comment = $page->comments()->first();
+        $resp = $this->postJson("/comment/$page->id", [
+            'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id
+        ]);
+        $resp->assertStatus(200);
+
+        $respHtml = $this->withHtml($this->get($page->getUrl()));
+        $respHtml->assertElementCount('.comment-branch', 4);
+        $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment');
+    }
+
+    public function test_comments_are_visible_in_the_page_editor()
+    {
+        $page = $this->entities->page();
+
+        $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']);
+
+        $respHtml = $this->withHtml($this->get($page->getUrl('/edit')));
+        $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
+    }
+
+    public function test_comment_creator_name_truncated()
+    {
+        [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']);
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes());
+
+        $pageResp = $this->asAdmin()->get($page->getUrl());
+        $pageResp->assertSee('Wolfeschlegels…');
+    }
+
+    public function test_comment_editor_js_loaded_with_create_or_edit_permissions()
+    {
+        $editor = $this->users->editor();
+        $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);
+
+        $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']);
+        $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);
+
+        Comment::factory()->create([
+            'created_by'  => $editor->id,
+            'entity_type' => 'page',
+            'entity_id'   => $page->id,
+        ]);
+
+        $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);
+    }
+
+    public function test_comment_displays_relative_times()
+    {
+        $page = $this->entities->page();
+        $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]);
+        $comment->created_at = now()->subWeek();
+        $comment->updated_at = now()->subDay();
+        $comment->save();
+
+        $pageResp = $this->asAdmin()->get($page->getUrl());
+        $html = $this->withHtml($pageResp);
+
+        // Create date shows relative time as text to user
+        $html->assertElementContains('.comment-box', 'commented 1 week ago');
+        // Updated indicator has full time as title
+        $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') .  '"]', 'Updated');
+    }
+
+    public function test_comment_displays_reference_if_set()
+    {
+        $page = $this->entities->page();
+        $comment = Comment::factory()->make([
+            'content_ref' => 'bkmrk-a:abc:4-1',
+            'local_id'   =>  10,
+        ]);
+        $page->comments()->save($comment);
+
+        $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+        $html->assertElementExists('#comment10 .comment-reference-indicator-wrap a');
+    }
+
+    public function test_archived_comments_are_shown_in_their_own_container()
+    {
+        $page = $this->entities->page();
+        $comment = Comment::factory()->make(['local_id' => 44]);
+        $page->comments()->save($comment);
+
+        $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+        $html->assertElementExists('#comment-tab-panel-active #comment44');
+        $html->assertElementNotExists('#comment-tab-panel-archived .comment-box');
+
+        $comment->archived = true;
+        $comment->save();
+
+        $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+        $html->assertElementExists('#comment-tab-panel-archived #comment44.comment-box');
+        $html->assertElementNotExists('#comment-tab-panel-active #comment44');
+    }
+}
similarity index 56%
rename from tests/Entity/CommentTest.php
rename to tests/Entity/CommentStoreTest.php
index 9e019e3d1485d99f6924422cd30a25bc130461c7..8b8a5d488b869447d3a8f905c57324c6dc1c7b6a 100644 (file)
@@ -7,7 +7,7 @@ use BookStack\Activity\Models\Comment;
 use BookStack\Entities\Models\Page;
 use Tests\TestCase;
 
-class CommentTest extends TestCase
+class CommentStoreTest extends TestCase
 {
     public function test_add_comment()
     {
@@ -33,6 +33,32 @@ class CommentTest extends TestCase
 
         $this->assertActivityExists(ActivityType::COMMENT_CREATE);
     }
+    public function test_add_comment_stores_content_reference_only_if_format_valid()
+    {
+        $validityByRefs = [
+            'bkmrk-my-title:4589284922:4-3' => true,
+            'bkmrk-my-title:4589284922:' => true,
+            'bkmrk-my-title:4589284922:abc' => false,
+            'my-title:4589284922:' => false,
+            'bkmrk-my-title-4589284922:' => false,
+        ];
+
+        $page = $this->entities->page();
+
+        foreach ($validityByRefs as $ref => $valid) {
+            $this->asAdmin()->postJson("/comment/$page->id", [
+                'html' => '<p>My comment</p>',
+                'parent_id' => null,
+                'content_ref' => $ref,
+            ]);
+
+            if ($valid) {
+                $this->assertDatabaseHas('comments', ['entity_id' => $page->id, 'content_ref' => $ref]);
+            } else {
+                $this->assertDatabaseMissing('comments', ['entity_id' => $page->id, 'content_ref' => $ref]);
+            }
+        }
+    }
 
     public function test_comment_edit()
     {
@@ -80,6 +106,89 @@ class CommentTest extends TestCase
         $this->assertActivityExists(ActivityType::COMMENT_DELETE);
     }
 
+    public function test_comment_archive_and_unarchive()
+    {
+        $this->asAdmin();
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $page->comments()->save($comment);
+        $comment->refresh();
+
+        $this->put("/comment/$comment->id/archive");
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $comment->id,
+            'archived' => true,
+        ]);
+
+        $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+
+        $this->put("/comment/$comment->id/unarchive");
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $comment->id,
+            'archived' => false,
+        ]);
+
+        $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+    }
+
+    public function test_archive_endpoints_require_delete_or_edit_permissions()
+    {
+        $viewer = $this->users->viewer();
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $page->comments()->save($comment);
+        $comment->refresh();
+
+        $endpoints = ["/comment/$comment->id/archive", "/comment/$comment->id/unarchive"];
+
+        foreach ($endpoints as $endpoint) {
+            $resp = $this->actingAs($viewer)->put($endpoint);
+            $this->assertPermissionError($resp);
+        }
+
+        $this->permissions->grantUserRolePermissions($viewer, ['comment-delete-all']);
+
+        foreach ($endpoints as $endpoint) {
+            $resp = $this->actingAs($viewer)->put($endpoint);
+            $resp->assertOk();
+        }
+
+        $this->permissions->removeUserRolePermissions($viewer, ['comment-delete-all']);
+        $this->permissions->grantUserRolePermissions($viewer, ['comment-update-all']);
+
+        foreach ($endpoints as $endpoint) {
+            $resp = $this->actingAs($viewer)->put($endpoint);
+            $resp->assertOk();
+        }
+    }
+
+    public function test_non_top_level_comments_cant_be_archived_or_unarchived()
+    {
+        $this->asAdmin();
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $page->comments()->save($comment);
+        $subComment = Comment::factory()->make(['parent_id' => $comment->id]);
+        $page->comments()->save($subComment);
+        $subComment->refresh();
+
+        $resp = $this->putJson("/comment/$subComment->id/archive");
+        $resp->assertStatus(400);
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $subComment->id,
+            'archived' => false,
+        ]);
+
+        $resp = $this->putJson("/comment/$subComment->id/unarchive");
+        $resp->assertStatus(400);
+    }
+
     public function test_scripts_cannot_be_injected_via_comment_html()
     {
         $page = $this->entities->page();
@@ -139,96 +248,4 @@ class CommentTest extends TestCase
             'html' => $expected,
         ]);
     }
-
-    public function test_reply_comments_are_nested()
-    {
-        $this->asAdmin();
-        $page = $this->entities->page();
-
-        $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
-        $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
-
-        $respHtml = $this->withHtml($this->get($page->getUrl()));
-        $respHtml->assertElementCount('.comment-branch', 3);
-        $respHtml->assertElementNotExists('.comment-branch .comment-branch');
-
-        $comment = $page->comments()->first();
-        $resp = $this->postJson("/comment/$page->id", [
-            'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id
-        ]);
-        $resp->assertStatus(200);
-
-        $respHtml = $this->withHtml($this->get($page->getUrl()));
-        $respHtml->assertElementCount('.comment-branch', 4);
-        $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment');
-    }
-
-    public function test_comments_are_visible_in_the_page_editor()
-    {
-        $page = $this->entities->page();
-
-        $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']);
-
-        $respHtml = $this->withHtml($this->get($page->getUrl('/edit')));
-        $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
-    }
-
-    public function test_comment_creator_name_truncated()
-    {
-        [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']);
-        $page = $this->entities->page();
-
-        $comment = Comment::factory()->make();
-        $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes());
-
-        $pageResp = $this->asAdmin()->get($page->getUrl());
-        $pageResp->assertSee('Wolfeschlegels…');
-    }
-
-    public function test_comment_editor_js_loaded_with_create_or_edit_permissions()
-    {
-        $editor = $this->users->editor();
-        $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);
-
-        $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']);
-        $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);
-
-        Comment::factory()->create([
-            'created_by'  => $editor->id,
-            'entity_type' => 'page',
-            'entity_id'   => $page->id,
-        ]);
-
-        $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);
-    }
-
-    public function test_comment_displays_relative_times()
-    {
-        $page = $this->entities->page();
-        $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]);
-        $comment->created_at = now()->subWeek();
-        $comment->updated_at = now()->subDay();
-        $comment->save();
-
-        $pageResp = $this->asAdmin()->get($page->getUrl());
-        $html = $this->withHtml($pageResp);
-
-        // Create date shows relative time as text to user
-        $html->assertElementContains('.comment-box', 'commented 1 week ago');
-        // Updated indicator has full time as title
-        $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') .  '"]', 'Updated');
-    }
 }
diff --git a/tests/Uploads/ImageStorageTest.php b/tests/Uploads/ImageStorageTest.php
new file mode 100644 (file)
index 0000000..117da7f
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+namespace Tests\Uploads;
+
+use BookStack\Uploads\ImageStorage;
+use Tests\TestCase;
+
+class ImageStorageTest extends TestCase
+{
+    public function test_local_image_storage_sets_755_directory_permissions()
+    {
+        if (PHP_OS_FAMILY !== 'Linux') {
+            $this->markTestSkipped('Test only works on Linux');
+        }
+
+        config()->set('filesystems.default', 'local');
+        $storage = $this->app->make(ImageStorage::class);
+        $dirToCheck = 'test-dir-perms-' . substr(md5(random_bytes(16)), 0, 6);
+
+        $disk = $storage->getDisk('gallery');
+        $disk->put("{$dirToCheck}/image.png", 'abc', true);
+
+        $expectedPath = public_path("uploads/images/{$dirToCheck}");
+        $permissionsApplied = substr(sprintf('%o', fileperms($expectedPath)), -4);
+        $this->assertEquals('0755', $permissionsApplied);
+
+        @unlink("{$expectedPath}/image.png");
+        @rmdir($expectedPath);
+    }
+}