1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-11-06 00:50:36 +03:00

API: Added comment tree to pages-read endpoint

Includes tests to cover
This commit is contained in:
Dan Brown
2025-10-24 10:18:52 +01:00
parent fcacf7cacb
commit 4627dfd4f7
5 changed files with 69 additions and 8 deletions

View 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 * 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 * and refers to the 'local_id' of the parent comment on the same page, not the main
* globally unique 'id'. * 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 class CommentApiController extends ApiController
{ {
// TODO - Add tree-style comment listing to page-show responses.
protected array $rules = [ protected array $rules = [
'create' => [ 'create' => [
'page_id' => ['required', 'integer'], 'page_id' => ['required', 'integer'],

View File

@@ -13,6 +13,11 @@ class CommentTree
* @var CommentTreeNode[] * @var CommentTreeNode[]
*/ */
protected array $tree; protected array $tree;
/**
* A linear array of loaded comments.
* @var Comment[]
*/
protected array $comments; protected array $comments;
public function __construct( public function __construct(
@@ -39,7 +44,7 @@ class CommentTree
public function getActive(): array 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 public function activeThreadCount(): int
@@ -49,7 +54,7 @@ class CommentTree
public function getArchived(): array 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 public function archivedThreadCount(): int
@@ -79,6 +84,14 @@ class CommentTree
return false; return false;
} }
public function loadVisibleHtml(): void
{
foreach ($this->comments as $comment) {
$comment->setAttribute('html', $comment->safeHtml());
$comment->makeVisible('html');
}
}
/** /**
* @param Comment[] $comments * @param Comment[] $comments
* @return CommentTreeNode[] * @return CommentTreeNode[]
@@ -123,6 +136,9 @@ class CommentTree
return new CommentTreeNode($byId[$id], $depth, $children); return new CommentTreeNode($byId[$id], $depth, $children);
} }
/**
* @return Comment[]
*/
protected function loadComments(): array protected function loadComments(): array
{ {
if (!$this->enabled()) { if (!$this->enabled()) {

View File

@@ -2,6 +2,7 @@
namespace BookStack\Entities\Controllers; namespace BookStack\Entities\Controllers;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Repos\PageRepo;
@@ -88,21 +89,32 @@ class PageApiController extends ApiController
/** /**
* View the details of a single page. * View the details of a single page.
* Pages will always have HTML content. They may have markdown content * 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. * would show on page view, with page includes handled.
* The 'raw_html' property is the direct database stored HTML content, which would be * The 'raw_html' property is the direct database stored HTML content, which would be
* what BookStack shows on page edit. * what BookStack shows on page edit.
* *
* See the "Content Security" section of these docs for security considerations when using * See the "Content Security" section of these docs for security considerations when using
* the page content returned from this endpoint. * 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) public function read(string $id)
{ {
$page = $this->queries->findVisibleByIdOrFail($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);
} }
/** /**

View File

@@ -13,6 +13,11 @@ class CommentFactory extends Factory
*/ */
protected $model = \BookStack\Activity\Models\Comment::class; 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. * Define the model's default state.
* *
@@ -22,11 +27,12 @@ class CommentFactory extends Factory
{ {
$text = $this->faker->paragraph(1); $text = $this->faker->paragraph(1);
$html = '<p>' . $text . '</p>'; $html = '<p>' . $text . '</p>';
$nextLocalId = static::$nextLocalId++;
return [ return [
'html' => $html, 'html' => $html,
'parent_id' => null, 'parent_id' => null,
'local_id' => 1, 'local_id' => $nextLocalId,
'content_ref' => '', 'content_ref' => '',
'archived' => false, 'archived' => false,
]; ];

View File

@@ -2,6 +2,7 @@
namespace Tests\Api; namespace Tests\Api;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use Carbon\Carbon; use Carbon\Carbon;
@@ -199,6 +200,31 @@ class PagesApiTest extends TestCase
$this->assertSame(404, $resp->json('error')['code']); $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() public function test_update_endpoint()
{ {
$this->actingAsApiEditor(); $this->actingAsApiEditor();