From 4627dfd4f7ead66cba4ba2e042f0417c4a5db853 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 24 Oct 2025 10:18:52 +0100 Subject: [PATCH] API: Added comment tree to pages-read endpoint Includes tests to cover --- .../Controllers/CommentApiController.php | 5 ++-- app/Activity/Tools/CommentTree.php | 20 ++++++++++++-- .../Controllers/PageApiController.php | 18 ++++++++++--- .../Activity/Models/CommentFactory.php | 8 +++++- tests/Api/PagesApiTest.php | 26 +++++++++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 92551bf36..6c60de9da 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -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'], diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php index 66df29430..68f4a94d3 100644 --- a/app/Activity/Tools/CommentTree.php +++ b/app/Activity/Tools/CommentTree.php @@ -13,6 +13,11 @@ class CommentTree * @var CommentTreeNode[] */ protected array $tree; + + /** + * A linear array of loaded comments. + * @var Comment[] + */ protected array $comments; public function __construct( @@ -39,7 +44,7 @@ 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()) { diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 033c19a7a..197018cca 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -2,6 +2,7 @@ namespace BookStack\Entities\Controllers; +use BookStack\Activity\Tools\CommentTree; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\PageRepo; @@ -88,21 +89,32 @@ 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); } /** diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php index 844bc3993..81022e0d4 100644 --- a/database/factories/Activity/Models/CommentFactory.php +++ b/database/factories/Activity/Models/CommentFactory.php @@ -13,6 +13,11 @@ class CommentFactory extends Factory */ protected $model = \BookStack\Activity\Models\Comment::class; + /** + * A static counter to provide a unique local_id for each comment. + */ + protected static int $nextLocalId = 1000; + /** * Define the model's default state. * @@ -22,11 +27,12 @@ class CommentFactory extends Factory { $text = $this->faker->paragraph(1); $html = '

' . $text . '

'; + $nextLocalId = static::$nextLocalId++; return [ 'html' => $html, 'parent_id' => null, - 'local_id' => 1, + 'local_id' => $nextLocalId, 'content_ref' => '', 'archived' => false, ]; diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index 8caf85aff..d71b6c988 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -2,6 +2,7 @@ namespace Tests\Api; +use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use Carbon\Carbon; @@ -199,6 +200,31 @@ 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' => '

My active comment

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

My active comment

', + ]); + } + public function test_update_endpoint() { $this->actingAsApiEditor();