From 1c8102bb897bc479abfcc3432353907d90654b1e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 22 Nov 2020 14:56:19 +0000 Subject: [PATCH 1/5] Started pages API --- app/Entities/Models/Page.php | 5 + app/Entities/Repos/PageRepo.php | 36 ++--- app/Entities/Tools/PageContent.php | 22 +++ app/Http/Controllers/Api/ApiController.php | 2 +- .../Controllers/Api/PageApiController.php | 126 ++++++++++++++++++ routes/api.php | 6 + 6 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 app/Http/Controllers/Api/PageApiController.php diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index b3eb21321..b0a3e2d31 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -29,6 +29,11 @@ class Page extends BookChild protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot']; + protected $casts = [ + 'draft' => 'boolean', + 'template' => 'boolean', + ]; + /** * Get the entities that are visible to the current user. */ diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 9c6da0a81..153ef8575 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -35,9 +35,9 @@ class PageRepo * Get a page by ID. * @throws NotFoundException */ - public function getById(int $id): Page + public function getById(int $id, array $relations = ['book']): Page { - $page = Page::visible()->with(['book'])->find($id); + $page = Page::visible()->with($relations)->find($id); if (!$page) { throw new NotFoundException(trans('errors.page_not_found')); @@ -152,12 +152,8 @@ class PageRepo public function publishDraft(Page $draft, array $input): Page { $this->baseRepo->update($draft, $input); - if (isset($input['template']) && userCan('templates-manage')) { - $draft->template = ($input['template'] === 'true'); - } + $this->updateTemplateStatusAndContentFromInput($draft, $input); - $pageContent = new PageContent($draft); - $pageContent->setNewHTML($input['html']); $draft->draft = false; $draft->revision_count = 1; $draft->priority = $this->getNewPriority($draft); @@ -181,12 +177,7 @@ class PageRepo $oldHtml = $page->html; $oldName = $page->name; - if (isset($input['template']) && userCan('templates-manage')) { - $page->template = ($input['template'] === 'true'); - } - - $pageContent = new PageContent($page); - $pageContent->setNewHTML($input['html']); + $this->updateTemplateStatusAndContentFromInput($page, $input); $this->baseRepo->update($page, $input); // Update with new details @@ -211,6 +202,20 @@ class PageRepo return $page; } + protected function updateTemplateStatusAndContentFromInput(Page $page, array $input) + { + if (isset($input['template']) && userCan('templates-manage')) { + $page->template = ($input['template'] === 'true'); + } + + $pageContent = new PageContent($page); + if (isset($input['html'])) { + $pageContent->setNewHTML($input['html']); + } else { + $pageContent->setNewMarkdown($input['markdown']); + } + } + /** * Saves a page revision into the system. */ @@ -243,11 +248,10 @@ class PageRepo { // If the page itself is a draft simply update that if ($page->draft) { - $page->fill($input); if (isset($input['html'])) { - $content = new PageContent($page); - $content->setNewHTML($input['html']); + (new PageContent($page))->setNewHTML($input['html']); } + $page->fill($input); $page->save(); return $page; } diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 011e1b2ac..f60971b8b 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -4,6 +4,7 @@ use BookStack\Entities\Models\Page; use DOMDocument; use DOMNodeList; use DOMXPath; +use League\CommonMark\CommonMarkConverter; class PageContent { @@ -25,6 +26,27 @@ class PageContent { $this->page->html = $this->formatHtml($html); $this->page->text = $this->toPlainText(); + $this->page->markdown = ''; + } + + /** + * Update the content of the page with new provided Markdown content. + */ + public function setNewMarkdown(string $markdown) + { + $this->page->markdown = $markdown; + $html = $this->markdownToHtml($markdown); + $this->page->html = $this->formatHtml($html); + $this->page->text = $this->toPlainText(); + } + + /** + * Convert the given Markdown content to a HTML string. + */ + protected function markdownToHtml(string $markdown): string + { + $converter = new CommonMarkConverter(); + return $converter->convertToHtml($markdown); } /** diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 65a5bb99f..0a3d89453 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -5,7 +5,7 @@ use BookStack\Http\Controllers\Controller; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; -class ApiController extends Controller +abstract class ApiController extends Controller { protected $rules = []; diff --git a/app/Http/Controllers/Api/PageApiController.php b/app/Http/Controllers/Api/PageApiController.php new file mode 100644 index 000000000..2fc4e3b36 --- /dev/null +++ b/app/Http/Controllers/Api/PageApiController.php @@ -0,0 +1,126 @@ + [ + 'book_id' => 'required_unless:chapter_id|integer', + 'chapter_id' => 'required_unless:book_id|integer', + 'name' => 'required|string|max:255', + 'html' => 'required_without:markdown|string', + 'markdown' => 'required_without:html|string', + 'tags' => 'array', + ], + 'update' => [ + 'book_id' => 'required|integer', + 'chapter_id' => 'required|integer', + 'name' => 'string|min:1|max:255', + 'html' => 'string', + 'markdown' => 'string', + 'tags' => 'array', + ], + ]; + + public function __construct(PageRepo $pageRepo) + { + $this->pageRepo = $pageRepo; + } + + /** + * Get a listing of pages visible to the user. + */ + public function list() + { + $pages = Page::visible(); + return $this->apiListingResponse($pages, [ + 'id', 'book_id', 'chapter_id', 'name', 'slug', 'priority', + 'draft', 'template', + 'created_at', 'updated_at', 'created_by', 'updated_by', + ]); + } + + /** + * Create a new page in the system. + */ + public function create(Request $request) + { + $this->validate($request, $this->rules['create']); + + if ($request->has('chapter_id')) { + $parent = Chapter::visible()->findOrFail($request->get('chapter_id')); + } else { + $parent = Book::visible()->findOrFail($request->get('book_id')); + } + $this->checkOwnablePermission('page-create', $parent); + + $draft = $this->pageRepo->getNewDraftPage($parent); + $this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create']))); + + return response()->json($draft->load(['tags'])); + } + + /** + * View the details of a single page. + */ + public function read(string $id) + { + $page = $this->pageRepo->getById($id, ['tags', 'createdBy', 'updatedBy']); + return response()->json($page); + } + + /** + * Update the details of a single page. + */ + public function update(Request $request, string $id) + { + $page = $this->pageRepo->getById($id, []); + $this->checkOwnablePermission('page-update', $page); + + $parent = null; + if ($request->has('chapter_id')) { + $parent = Chapter::visible()->findOrFail($request->get('chapter_id')); + } else if ($request->has('book_id')) { + $parent = Book::visible()->findOrFail($request->get('book_id')); + } + + if ($parent && !$parent->matches($page->getParent())) { + $this->checkOwnablePermission('page-delete', $page); + try { + $this->pageRepo->move($page, $parent->getType() . ':' . $parent->id); + } catch (Exception $exception) { + if ($exception instanceof PermissionsException) { + $this->showPermissionError(); + } + + return $this->jsonError(trans('errors.selected_book_chapter_not_found')); + } + } + + $updatedPage = $this->pageRepo->update($page, $request->all()); + return response()->json($updatedPage->load(['tags'])); + } + + /** + * Delete a page from the system. + */ + public function delete(string $id) + { + $page = $this->pageRepo->getById($id, []); + $this->checkOwnablePermission('page-delete', $page); + + $this->pageRepo->destroy($page); + return response('', 204); + } +} diff --git a/routes/api.php b/routes/api.php index 1b90d9b8f..d02e2311b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -34,3 +34,9 @@ Route::post('shelves', 'BookshelfApiController@create'); Route::get('shelves/{id}', 'BookshelfApiController@read'); Route::put('shelves/{id}', 'BookshelfApiController@update'); Route::delete('shelves/{id}', 'BookshelfApiController@delete'); + +Route::get('pages', 'PageApiController@list'); +Route::post('pages', 'PageApiController@create'); +Route::get('pages/{id}', 'PageApiController@read'); +Route::put('pages/{id}', 'PageApiController@update'); +Route::delete('pages/{id}', 'PageApiController@delete'); From 53bcfe528d96938132eb168433ea1fee9b9fd935 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 28 Nov 2020 15:21:54 +0000 Subject: [PATCH 2/5] Added pages API doc examples Made some tweaks to related content and other examples while there. --- app/Actions/Tag.php | 2 +- app/Entities/Models/Book.php | 2 +- app/Entities/Models/Bookshelf.php | 2 +- app/Entities/Models/Chapter.php | 2 +- app/Entities/Models/Page.php | 14 +++++- .../Controllers/Api/BookApiController.php | 3 +- .../Api/BookshelfApiController.php | 3 +- .../Controllers/Api/ChapterApiController.php | 3 +- .../Controllers/Api/PageApiController.php | 28 ++++++++--- dev/api/requests/pages-create.json | 9 ++++ dev/api/requests/pages-update.json | 9 ++++ dev/api/responses/books-read.json | 6 +-- dev/api/responses/chapters-read.json | 12 ++--- dev/api/responses/pages-create.json | 35 ++++++++++++++ dev/api/responses/pages-list.json | 47 +++++++++++++++++++ dev/api/responses/pages-read.json | 35 ++++++++++++++ dev/api/responses/pages-update.json | 35 ++++++++++++++ dev/api/responses/shelves-read.json | 6 +-- 18 files changed, 221 insertions(+), 32 deletions(-) create mode 100644 dev/api/requests/pages-create.json create mode 100644 dev/api/requests/pages-update.json create mode 100644 dev/api/responses/pages-create.json create mode 100644 dev/api/responses/pages-list.json create mode 100644 dev/api/responses/pages-read.json create mode 100644 dev/api/responses/pages-update.json diff --git a/app/Actions/Tag.php b/app/Actions/Tag.php index 709b1ddeb..5968ffe6d 100644 --- a/app/Actions/Tag.php +++ b/app/Actions/Tag.php @@ -5,7 +5,7 @@ use BookStack\Model; class Tag extends Model { protected $fillable = ['name', 'value', 'order']; - protected $hidden = ['id', 'entity_id', 'entity_type']; + protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at']; /** * Get the entity that this tag belongs to diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index afa2cde49..6c5676765 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -18,7 +18,7 @@ class Book extends Entity implements HasCoverImage public $searchFactor = 2; protected $fillable = ['name', 'description']; - protected $hidden = ['restricted', 'pivot', 'image_id']; + protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at']; /** * Get the url for this book. diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index edba8f61f..8ffd06d2e 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -12,7 +12,7 @@ class Bookshelf extends Entity implements HasCoverImage protected $fillable = ['name', 'description', 'image_id']; - protected $hidden = ['restricted', 'image_id']; + protected $hidden = ['restricted', 'image_id', 'deleted_at']; /** * Get the books in this shelf. diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index fc1d2c9d5..d736e2108 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -11,7 +11,7 @@ class Chapter extends BookChild public $searchFactor = 1.3; protected $fillable = ['name', 'description', 'priority', 'book_id']; - protected $hidden = ['restricted', 'pivot']; + protected $hidden = ['restricted', 'pivot', 'deleted_at']; /** * Get the pages that this chapter contains. diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index b0a3e2d31..52c64f048 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -1,5 +1,6 @@ 'boolean', @@ -114,4 +115,15 @@ class Page extends BookChild { return $this->revisions()->first(); } + + /** + * Get this page for JSON display. + */ + public function forJsonDisplay(): Page + { + $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy']); + $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown'])); + $refreshed->html = (new PageContent($refreshed))->render(); + return $refreshed; + } } diff --git a/app/Http/Controllers/Api/BookApiController.php b/app/Http/Controllers/Api/BookApiController.php index b7a5796c6..1b25b9645 100644 --- a/app/Http/Controllers/Api/BookApiController.php +++ b/app/Http/Controllers/Api/BookApiController.php @@ -79,7 +79,8 @@ class BookApiController extends ApiController } /** - * Delete a single book from the system. + * Delete a single book. + * This will typically send the book to the recycle bin. * @throws \Exception */ public function delete(string $id) diff --git a/app/Http/Controllers/Api/BookshelfApiController.php b/app/Http/Controllers/Api/BookshelfApiController.php index 7f6aaa69f..c4851b003 100644 --- a/app/Http/Controllers/Api/BookshelfApiController.php +++ b/app/Http/Controllers/Api/BookshelfApiController.php @@ -100,7 +100,8 @@ class BookshelfApiController extends ApiController /** - * Delete a single shelf from the system. + * Delete a single shelf. + * This will typically send the shelf to the recycle bin. * @throws Exception */ public function delete(string $id) diff --git a/app/Http/Controllers/Api/ChapterApiController.php b/app/Http/Controllers/Api/ChapterApiController.php index c7dd38c1b..e69aecc2d 100644 --- a/app/Http/Controllers/Api/ChapterApiController.php +++ b/app/Http/Controllers/Api/ChapterApiController.php @@ -86,7 +86,8 @@ class ChapterApiController extends ApiController } /** - * Delete a chapter from the system. + * Delete a chapter. + * This will typically send the chapter to the recycle bin. */ public function delete(string $id) { diff --git a/app/Http/Controllers/Api/PageApiController.php b/app/Http/Controllers/Api/PageApiController.php index 2fc4e3b36..0b3323ccd 100644 --- a/app/Http/Controllers/Api/PageApiController.php +++ b/app/Http/Controllers/Api/PageApiController.php @@ -16,8 +16,8 @@ class PageApiController extends ApiController protected $rules = [ 'create' => [ - 'book_id' => 'required_unless:chapter_id|integer', - 'chapter_id' => 'required_unless:book_id|integer', + 'book_id' => 'required_without:chapter_id|integer', + 'chapter_id' => 'required_without:book_id|integer', 'name' => 'required|string|max:255', 'html' => 'required_without:markdown|string', 'markdown' => 'required_without:html|string', @@ -53,6 +53,12 @@ class PageApiController extends ApiController /** * Create a new page in the system. + * + * The ID of a parent book or chapter is required to indicate + * where this page should be located. + * + * Any HTML content provided should be kept to a single-block depth of plain HTML + * elements to remain compatible with the BookStack front-end and editors. */ public function create(Request $request) { @@ -68,20 +74,27 @@ class PageApiController extends ApiController $draft = $this->pageRepo->getNewDraftPage($parent); $this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create']))); - return response()->json($draft->load(['tags'])); + return response()->json($draft->forJsonDisplay()); } /** * 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. */ public function read(string $id) { - $page = $this->pageRepo->getById($id, ['tags', 'createdBy', 'updatedBy']); - return response()->json($page); + $page = $this->pageRepo->getById($id, []); + return response()->json($page->forJsonDisplay()); } /** * Update the details of a single page. + * + * See the 'create' action for details on the provided HTML/Markdown. + * Providing a 'book_id' or 'chapter_id' property will essentially move + * the page into that parent element if you have permissions to do so. */ public function update(Request $request, string $id) { @@ -109,11 +122,12 @@ class PageApiController extends ApiController } $updatedPage = $this->pageRepo->update($page, $request->all()); - return response()->json($updatedPage->load(['tags'])); + return response()->json($updatedPage->forJsonDisplay()); } /** - * Delete a page from the system. + * Delete a page. + * This will typically send the page to the recycle bin. */ public function delete(string $id) { diff --git a/dev/api/requests/pages-create.json b/dev/api/requests/pages-create.json new file mode 100644 index 000000000..1f53b42d4 --- /dev/null +++ b/dev/api/requests/pages-create.json @@ -0,0 +1,9 @@ +{ + "book_id": 1, + "name": "My API Page", + "html": "

my new API page

", + "tags": [ + {"name": "Category", "value": "Not Bad Content"}, + {"name": "Rating", "value": "Average"} + ] +} \ No newline at end of file diff --git a/dev/api/requests/pages-update.json b/dev/api/requests/pages-update.json new file mode 100644 index 000000000..b9bfeb630 --- /dev/null +++ b/dev/api/requests/pages-update.json @@ -0,0 +1,9 @@ +{ + "chapter_id": 1, + "name": "My updated API Page", + "html": "

my new API page - Updated

", + "tags": [ + {"name": "Category", "value": "API Examples"}, + {"name": "Rating", "value": "Alright"} + ] +} \ No newline at end of file diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json index 2e43f5f87..815a71c35 100644 --- a/dev/api/responses/books-read.json +++ b/dev/api/responses/books-read.json @@ -16,13 +16,9 @@ "tags": [ { "id": 13, - "entity_id": 16, - "entity_type": "BookStack\\Book", "name": "Category", "value": "Guide", - "order": 0, - "created_at": "2020-01-12 14:11:51", - "updated_at": "2020-01-12 14:11:51" + "order": 0 } ], "cover": { diff --git a/dev/api/responses/chapters-read.json b/dev/api/responses/chapters-read.json index 2eddad895..0d16f4b6a 100644 --- a/dev/api/responses/chapters-read.json +++ b/dev/api/responses/chapters-read.json @@ -19,9 +19,7 @@ { "name": "Category", "value": "Guide", - "order": 0, - "created_at": "2020-05-22 22:51:51", - "updated_at": "2020-05-22 22:51:51" + "order": 0 } ], "pages": [ @@ -36,9 +34,9 @@ "updated_at": "2019-08-26 14:32:59", "created_by": 1, "updated_by": 1, - "draft": 0, + "draft": false, "revision_count": 2, - "template": 0 + "template": false }, { "id": 7, @@ -51,9 +49,9 @@ "updated_at": "2019-06-06 12:03:04", "created_by": 3, "updated_by": 3, - "draft": 0, + "draft": false, "revision_count": 1, - "template": 0 + "template": false } ] } \ No newline at end of file diff --git a/dev/api/responses/pages-create.json b/dev/api/responses/pages-create.json new file mode 100644 index 000000000..1f6c970fb --- /dev/null +++ b/dev/api/responses/pages-create.json @@ -0,0 +1,35 @@ +{ + "id": 358, + "book_id": 1, + "chapter_id": 0, + "name": "My API Page", + "slug": "my-api-page", + "html": "

my new API page

", + "priority": 14, + "created_at": "2020-11-28 15:01:39", + "updated_at": "2020-11-28 15:01:39", + "created_by": { + "id": 1, + "name": "Admin" + }, + "updated_by": { + "id": 1, + "name": "Admin" + }, + "draft": false, + "markdown": "", + "revision_count": 1, + "template": false, + "tags": [ + { + "name": "Category", + "value": "Not Bad Content", + "order": 0 + }, + { + "name": "Rating", + "value": "Average", + "order": 1 + } + ] +} \ No newline at end of file diff --git a/dev/api/responses/pages-list.json b/dev/api/responses/pages-list.json new file mode 100644 index 000000000..973934516 --- /dev/null +++ b/dev/api/responses/pages-list.json @@ -0,0 +1,47 @@ +{ + "data": [ + { + "id": 1, + "book_id": 1, + "chapter_id": 1, + "name": "How to create page content", + "slug": "how-to-create-page-content", + "priority": 0, + "draft": false, + "template": false, + "created_at": "2019-05-05 21:49:58", + "updated_at": "2020-07-04 15:50:58", + "created_by": 1, + "updated_by": 1 + }, + { + "id": 2, + "book_id": 1, + "chapter_id": 1, + "name": "How to use images", + "slug": "how-to-use-images", + "priority": 2, + "draft": false, + "template": false, + "created_at": "2019-05-05 21:53:30", + "updated_at": "2019-06-06 12:03:04", + "created_by": 1, + "updated_by": 1 + }, + { + "id": 3, + "book_id": 1, + "chapter_id": 1, + "name": "Drawings via draw.io", + "slug": "drawings-via-drawio", + "priority": 3, + "draft": false, + "template": false, + "created_at": "2019-05-05 21:53:49", + "updated_at": "2019-12-18 21:56:52", + "created_by": 1, + "updated_by": 1 + } + ], + "total": 322 +} \ No newline at end of file diff --git a/dev/api/responses/pages-read.json b/dev/api/responses/pages-read.json new file mode 100644 index 000000000..c8acb520a --- /dev/null +++ b/dev/api/responses/pages-read.json @@ -0,0 +1,35 @@ +{ + "id": 306, + "book_id": 1, + "chapter_id": 0, + "name": "A page written in markdown", + "slug": "a-page-written-in-markdown", + "html": "

How this is built

\r\n

This page is written in markdown. BookStack stores the page data in HTML.

\r\n

Here's a cute picture of my cat:

\r\n

\"yXSrubes.jpg\"

", + "priority": 13, + "created_at": "2020-02-02 21:40:38", + "updated_at": "2020-11-28 14:43:20", + "created_by": { + "id": 1, + "name": "Admin" + }, + "updated_by": { + "id": 1, + "name": "Admin" + }, + "draft": false, + "markdown": "# How this is built\r\n\r\nThis page is written in markdown. BookStack stores the page data in HTML.\r\n\r\nHere's a cute picture of my cat:\r\n\r\n[![yXSrubes.jpg](http://example.com/uploads/images/gallery/2020-04/scaled-1680-/yXSrubes.jpg)](http://example.com/uploads/images/gallery/2020-04/yXSrubes.jpg)", + "revision_count": 5, + "template": false, + "tags": [ + { + "name": "Category", + "value": "Top Content", + "order": 0 + }, + { + "name": "Animal", + "value": "Cat", + "order": 1 + } + ] +} \ No newline at end of file diff --git a/dev/api/responses/pages-update.json b/dev/api/responses/pages-update.json new file mode 100644 index 000000000..23f8d221c --- /dev/null +++ b/dev/api/responses/pages-update.json @@ -0,0 +1,35 @@ +{ + "id": 361, + "book_id": 1, + "chapter_id": 1, + "name": "My updated API Page", + "slug": "my-updated-api-page", + "html": "

my new API page - Updated

", + "priority": 16, + "created_at": "2020-11-28 15:10:54", + "updated_at": "2020-11-28 15:13:03", + "created_by": { + "id": 1, + "name": "Admin" + }, + "updated_by": { + "id": 1, + "name": "Admin" + }, + "draft": false, + "markdown": "", + "revision_count": 5, + "template": false, + "tags": [ + { + "name": "Category", + "value": "API Examples", + "order": 0 + }, + { + "name": "Rating", + "value": "Alright", + "order": 0 + } + ] +} \ No newline at end of file diff --git a/dev/api/responses/shelves-read.json b/dev/api/responses/shelves-read.json index 634fbb5a5..b0487debe 100644 --- a/dev/api/responses/shelves-read.json +++ b/dev/api/responses/shelves-read.json @@ -16,13 +16,9 @@ "tags": [ { "id": 16, - "entity_id": 14, - "entity_type": "BookStack\\Bookshelf", "name": "Category", "value": "Guide", - "order": 0, - "created_at": "2020-04-10 13:31:04", - "updated_at": "2020-04-10 13:31:04" + "order": 0 } ], "cover": { From 875a8bdafffcf7641ec4f19c95dbaad03bce0138 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 28 Nov 2020 15:28:44 +0000 Subject: [PATCH 3/5] Made docs sidebar a slight bit easier to scroll Now it easily goes off the page, made it indapentally scrollable. Will probably do something different in future as it grows more. --- resources/sass/_blocks.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index 5b219b9ae..75adf12aa 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -274,4 +274,6 @@ .sticky-sidebar { position: sticky; top: $-m; + max-height: calc(100vh - #{$-m}); + overflow-y: auto; } From 8aedba14a3d75d26230cf9b7393bea72a1e70488 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 28 Nov 2020 15:39:40 +0000 Subject: [PATCH 4/5] Added page export API controller --- .../Api/BookExportApiController.php | 8 +--- .../Api/ChapterExportApiController.php | 4 +- .../Api/PageExportApiController.php | 47 +++++++++++++++++++ routes/api.php | 16 ++++--- 4 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 app/Http/Controllers/Api/PageExportApiController.php diff --git a/app/Http/Controllers/Api/BookExportApiController.php b/app/Http/Controllers/Api/BookExportApiController.php index a290d89e7..3d813c4d4 100644 --- a/app/Http/Controllers/Api/BookExportApiController.php +++ b/app/Http/Controllers/Api/BookExportApiController.php @@ -2,20 +2,14 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Tools\ExportFormatter; -use BookStack\Entities\Repos\BookRepo; use Throwable; class BookExportApiController extends ApiController { - protected $bookRepo; protected $exportFormatter; - /** - * BookExportController constructor. - */ - public function __construct(BookRepo $bookRepo, ExportFormatter $exportFormatter) + public function __construct(ExportFormatter $exportFormatter) { - $this->bookRepo = $bookRepo; $this->exportFormatter = $exportFormatter; } diff --git a/app/Http/Controllers/Api/ChapterExportApiController.php b/app/Http/Controllers/Api/ChapterExportApiController.php index ecbc6af1c..afdfe555d 100644 --- a/app/Http/Controllers/Api/ChapterExportApiController.php +++ b/app/Http/Controllers/Api/ChapterExportApiController.php @@ -7,15 +7,13 @@ use Throwable; class ChapterExportApiController extends ApiController { - protected $chapterRepo; protected $exportFormatter; /** * ChapterExportController constructor. */ - public function __construct(BookRepo $chapterRepo, ExportFormatter $exportFormatter) + public function __construct(ExportFormatter $exportFormatter) { - $this->chapterRepo = $chapterRepo; $this->exportFormatter = $exportFormatter; } diff --git a/app/Http/Controllers/Api/PageExportApiController.php b/app/Http/Controllers/Api/PageExportApiController.php new file mode 100644 index 000000000..7563092cb --- /dev/null +++ b/app/Http/Controllers/Api/PageExportApiController.php @@ -0,0 +1,47 @@ +exportFormatter = $exportFormatter; + } + + /** + * Export a page as a PDF file. + * @throws Throwable + */ + public function exportPdf(int $id) + { + $page = Page::visible()->findOrFail($id); + $pdfContent = $this->exportFormatter->pageToPdf($page); + return $this->downloadResponse($pdfContent, $page->slug . '.pdf'); + } + + /** + * Export a page as a contained HTML file. + * @throws Throwable + */ + public function exportHtml(int $id) + { + $page = Page::visible()->findOrFail($id); + $htmlContent = $this->exportFormatter->pageToContainedHtml($page); + return $this->downloadResponse($htmlContent, $page->slug . '.html'); + } + + /** + * Export a page as a plain text file. + */ + public function exportPlainText(int $id) + { + $page = Page::visible()->findOrFail($id); + $textContent = $this->exportFormatter->pageToPlainText($page); + return $this->downloadResponse($textContent, $page->slug . '.txt'); + } +} diff --git a/routes/api.php b/routes/api.php index d02e2311b..44643d6d4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,14 +29,18 @@ Route::get('chapters/{id}/export/html', 'ChapterExportApiController@exportHtml') Route::get('chapters/{id}/export/pdf', 'ChapterExportApiController@exportPdf'); Route::get('chapters/{id}/export/plaintext', 'ChapterExportApiController@exportPlainText'); -Route::get('shelves', 'BookshelfApiController@list'); -Route::post('shelves', 'BookshelfApiController@create'); -Route::get('shelves/{id}', 'BookshelfApiController@read'); -Route::put('shelves/{id}', 'BookshelfApiController@update'); -Route::delete('shelves/{id}', 'BookshelfApiController@delete'); - Route::get('pages', 'PageApiController@list'); Route::post('pages', 'PageApiController@create'); Route::get('pages/{id}', 'PageApiController@read'); Route::put('pages/{id}', 'PageApiController@update'); Route::delete('pages/{id}', 'PageApiController@delete'); + +Route::get('pages/{id}/export/html', 'PageExportApiController@exportHtml'); +Route::get('pages/{id}/export/pdf', 'PageExportApiController@exportPdf'); +Route::get('pages/{id}/export/plaintext', 'PageExportApiController@exportPlainText'); + +Route::get('shelves', 'BookshelfApiController@list'); +Route::post('shelves', 'BookshelfApiController@create'); +Route::get('shelves/{id}', 'BookshelfApiController@read'); +Route::put('shelves/{id}', 'BookshelfApiController@update'); +Route::delete('shelves/{id}', 'BookshelfApiController@delete'); From 57754c8211b0c76b9b5fb6046bfda0838660c289 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 28 Nov 2020 16:30:30 +0000 Subject: [PATCH 5/5] Added testing to cover the pages API --- tests/Api/PagesApiTest.php | 258 +++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 tests/Api/PagesApiTest.php diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php new file mode 100644 index 000000000..44fbf5e79 --- /dev/null +++ b/tests/Api/PagesApiTest.php @@ -0,0 +1,258 @@ +actingAsApiEditor(); + $firstPage = Page::query()->orderBy('id', 'asc')->first(); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJson(['data' => [ + [ + 'id' => $firstPage->id, + 'name' => $firstPage->name, + 'slug' => $firstPage->slug, + 'book_id' => $firstPage->book->id, + 'priority' => $firstPage->priority, + ] + ]]); + } + + public function test_create_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::query()->first(); + $details = [ + 'name' => 'My API page', + 'book_id' => $book->id, + 'html' => '

My new page content

', + 'tags' => [ + [ + 'name' => 'tagname', + 'value' => 'tagvalue', + ] + ] + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + unset($details['html']); + $resp->assertStatus(200); + $newItem = Page::query()->orderByDesc('id')->where('name', '=', $details['name'])->first(); + $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug])); + $this->assertDatabaseHas('tags', [ + 'entity_id' => $newItem->id, + 'entity_type' => $newItem->getMorphClass(), + 'name' => 'tagname', + 'value' => 'tagvalue', + ]); + $resp->assertSeeText('My new page content'); + $resp->assertJsonMissing(['book' => []]); + $this->assertActivityExists('page_create', $newItem); + } + + public function test_page_name_needed_to_create() + { + $this->actingAsApiEditor(); + $book = Book::query()->first(); + $details = [ + 'book_id' => $book->id, + 'html' => '

A page created via the API

', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(422); + $resp->assertJson($this->validationResponse([ + "name" => ["The name field is required."] + ])); + } + + public function test_book_id_or_chapter_id_needed_to_create() + { + $this->actingAsApiEditor(); + $details = [ + 'name' => 'My api page', + 'html' => '

A page created via the API

', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(422); + $resp->assertJson($this->validationResponse([ + "book_id" => ["The book id field is required when chapter id is not present."], + "chapter_id" => ["The chapter id field is required when book id is not present."] + ])); + + $chapter = Chapter::visible()->first(); + $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['chapter_id' => $chapter->id])); + $resp->assertStatus(200); + + $book = Book::visible()->first(); + $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['book_id' => $book->id])); + $resp->assertStatus(200); + } + + public function test_markdown_can_be_provided_for_create() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + $details = [ + 'book_id' => $book->id, + 'name' => 'My api page', + 'markdown' => "# A new API page \n[link](https://example.com)", + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertJson(['markdown' => $details['markdown']]); + + $respHtml = $resp->json('html'); + $this->assertStringContainsString('new API page', $respHtml); + $this->assertStringContainsString('link', $respHtml); + $this->assertStringContainsString('href="https://example.com"', $respHtml); + } + + public function test_read_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + + $resp = $this->getJson($this->baseEndpoint . "/{$page->id}"); + $resp->assertStatus(200); + $resp->assertJson([ + 'id' => $page->id, + 'slug' => $page->slug, + 'created_by' => [ + 'name' => $page->createdBy->name, + ], + 'book_id' => $page->book_id, + 'updated_by' => [ + 'name' => $page->createdBy->name, + ], + ]); + } + + public function test_read_endpoint_provides_rendered_html() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + $page->html = "

testing

Hello

"; + $page->save(); + + $resp = $this->getJson($this->baseEndpoint . "/{$page->id}"); + $html = $resp->json('html'); + $this->assertStringNotContainsString('script', $html); + $this->assertStringContainsString('Hello', $html); + $this->assertStringContainsString('testing', $html); + } + + public function test_update_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + $details = [ + 'name' => 'My updated API page', + 'html' => '

A page created via the API

', + 'tags' => [ + [ + 'name' => 'freshtag', + 'value' => 'freshtagval', + ] + ], + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details); + $page->refresh(); + + $resp->assertStatus(200); + unset($details['html']); + $resp->assertJson(array_merge($details, [ + 'id' => $page->id, 'slug' => $page->slug, 'book_id' => $page->book_id + ])); + $this->assertActivityExists('page_update', $page); + } + + public function test_providing_new_chapter_id_on_update_will_move_page() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first(); + $details = [ + 'name' => 'My updated API page', + 'chapter_id' => $chapter->id, + 'html' => '

A page created via the API

', + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details); + $resp->assertStatus(200); + $resp->assertJson([ + 'chapter_id' => $chapter->id, + 'book_id' => $chapter->book_id, + ]); + } + + public function test_providing_move_via_update_requires_page_create_permission_on_new_parent() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first(); + $this->setEntityRestrictions($chapter, ['view'], [$this->getEditor()->roles()->first()]); + $details = [ + 'name' => 'My updated API page', + 'chapter_id' => $chapter->id, + 'html' => '

A page created via the API

', + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details); + $resp->assertStatus(403); + } + + public function test_delete_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + $resp = $this->deleteJson($this->baseEndpoint . "/{$page->id}"); + + $resp->assertStatus(204); + $this->assertActivityExists('page_delete', $page); + } + + public function test_export_html_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); + } + + public function test_export_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); + } + + public function test_export_pdf_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); + } +} \ No newline at end of file