]> BookStack Code Mirror - bookstack/commitdiff
API: Added comment tree to pages-read endpoint
authorDan Brown <redacted>
Fri, 24 Oct 2025 09:18:52 +0000 (10:18 +0100)
committerDan Brown <redacted>
Fri, 24 Oct 2025 09:18:52 +0000 (10:18 +0100)
Includes tests to cover

app/Activity/Controllers/CommentApiController.php
app/Activity/Tools/CommentTree.php
app/Entities/Controllers/PageApiController.php
database/factories/Activity/Models/CommentFactory.php
tests/Api/PagesApiTest.php

index 92551bf36a1f6f3c2b48a8dd0f3926dabd236273..6c60de9da5220db87abb1789267b487196012548 100644 (file)
@@ -18,11 +18,12 @@ use Illuminate\Http\Response;
  * scoped to the page which the comment is on. The 'parent_id' is used for replies
  * and refers to the 'local_id' of the parent comment on the same page, not the main
  * globally unique 'id'.
+ *
+ * If you want to get all comments for a page in a tree-like structure, as reflected in
+ * the UI, then that is provided on pages-read API responses.
  */
 class CommentApiController extends ApiController
 {
-    // TODO - Add tree-style comment listing to page-show responses.
-
     protected array $rules = [
         'create' => [
             'page_id' => ['required', 'integer'],
index 66df294308676d384f50dc37db5b4dc735600b32..68f4a94d34d00f0fd14b199657010bea311b8c41 100644 (file)
@@ -13,6 +13,11 @@ class CommentTree
      * @var CommentTreeNode[]
      */
     protected array $tree;
+
+    /**
+     * A linear array of loaded comments.
+     * @var Comment[]
+     */
     protected array $comments;
 
     public function __construct(
@@ -39,7 +44,7 @@ class CommentTree
 
     public function getActive(): array
     {
-        return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
+        return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived));
     }
 
     public function activeThreadCount(): int
@@ -49,7 +54,7 @@ class CommentTree
 
     public function getArchived(): array
     {
-        return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
+        return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived));
     }
 
     public function archivedThreadCount(): int
@@ -79,6 +84,14 @@ class CommentTree
         return false;
     }
 
+    public function loadVisibleHtml(): void
+    {
+        foreach ($this->comments as $comment) {
+            $comment->setAttribute('html', $comment->safeHtml());
+            $comment->makeVisible('html');
+        }
+    }
+
     /**
      * @param Comment[] $comments
      * @return CommentTreeNode[]
@@ -123,6 +136,9 @@ class CommentTree
         return new CommentTreeNode($byId[$id], $depth, $children);
     }
 
+    /**
+     * @return Comment[]
+     */
     protected function loadComments(): array
     {
         if (!$this->enabled()) {
index 033c19a7a3312299003e00625382211eabef9d70..197018ccafec4495995d8d5b1bd0ebd256b045f4 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Entities\Controllers;
 
+use BookStack\Activity\Tools\CommentTree;
 use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Queries\PageQueries;
 use BookStack\Entities\Repos\PageRepo;
@@ -88,21 +89,32 @@ class PageApiController extends ApiController
     /**
      * View the details of a single page.
      * Pages will always have HTML content. They may have markdown content
-     * if the markdown editor was used to last update the page.
+     * if the Markdown editor was used to last update the page.
      *
-     * The 'html' property is the fully rendered & escaped HTML content that BookStack
+     * The 'html' property is the fully rendered and escaped HTML content that BookStack
      * would show on page view, with page includes handled.
      * The 'raw_html' property is the direct database stored HTML content, which would be
      * what BookStack shows on page edit.
      *
      * See the "Content Security" section of these docs for security considerations when using
      * the page content returned from this endpoint.
+     *
+     * Comments for the page are provided in a tree-structure representing the hierarchy of top-level
+     * comments and replies, for both archived and active comments.
      */
     public function read(string $id)
     {
         $page = $this->queries->findVisibleByIdOrFail($id);
 
-        return response()->json($page->forJsonDisplay());
+        $page = $page->forJsonDisplay();
+        $commentTree = (new CommentTree($page));
+        $commentTree->loadVisibleHtml();
+        $page->setAttribute('comments', [
+            'active' => $commentTree->getActive(),
+            'archived' => $commentTree->getArchived(),
+        ]);
+
+        return response()->json($page);
     }
 
     /**
index 844bc39938188bbe3d2e54d5c7932266862f5e84..81022e0d426180ad960e6681cc784c1d71190223 100644 (file)
@@ -13,6 +13,11 @@ class CommentFactory extends Factory
      */
     protected $model = \BookStack\Activity\Models\Comment::class;
 
+    /**
+     * A static counter to provide a unique local_id for each comment.
+     */
+    protected static int $nextLocalId = 1000;
+
     /**
      * Define the model's default state.
      *
@@ -22,11 +27,12 @@ class CommentFactory extends Factory
     {
         $text = $this->faker->paragraph(1);
         $html = '<p>' . $text . '</p>';
+        $nextLocalId = static::$nextLocalId++;
 
         return [
             'html'      => $html,
             'parent_id' => null,
-            'local_id'  => 1,
+            'local_id'  => $nextLocalId,
             'content_ref' => '',
             'archived' => false,
         ];
index 8caf85affbee2bc6e492142466b3251f98ee87c7..d71b6c9881d55f4c401665cdb0b35dca1d67f125 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Tests\Api;
 
+use BookStack\Activity\Models\Comment;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use Carbon\Carbon;
@@ -199,6 +200,31 @@ class PagesApiTest extends TestCase
         $this->assertSame(404, $resp->json('error')['code']);
     }
 
+    public function test_read_endpoint_includes_page_comments_tree_structure()
+    {
+        $this->actingAsApiEditor();
+        $page = $this->entities->page();
+        $relation = ['commentable_type' => 'page', 'commentable_id' => $page->id];
+        $active = Comment::factory()->create([...$relation, 'html' => '<p>My active<script>cat</script> comment</p>']);
+        Comment::factory()->count(5)->create([...$relation, 'parent_id' => $active->local_id]);
+        $archived = Comment::factory()->create([...$relation, 'archived' => true]);
+        Comment::factory()->count(2)->create([...$relation, 'parent_id' => $archived->local_id]);
+
+        $resp = $this->getJson("{$this->baseEndpoint}/{$page->id}");
+        $resp->assertOk();
+
+        $resp->assertJsonCount(1, 'comments.active');
+        $resp->assertJsonCount(1, 'comments.archived');
+        $resp->assertJsonCount(5, 'comments.active.0.children');
+        $resp->assertJsonCount(2, 'comments.archived.0.children');
+
+        $resp->assertJsonFragment([
+            'id' => $active->id,
+            'local_id' => $active->local_id,
+            'html' => '<p>My active comment</p>',
+        ]);
+    }
+
     public function test_update_endpoint()
     {
         $this->actingAsApiEditor();