class Tag extends Model
{
protected $fillable = ['name', 'value', 'order'];
+ protected $hidden = ['id', 'entity_id', 'entity_type'];
/**
* Get the entity that this tag belongs to
/**
* Save an array of tags to an entity
- * @param \BookStack\Entities\Entity $entity
- * @param array $tags
* @return array|\Illuminate\Database\Eloquent\Collection
*/
- public function saveTagsToEntity(Entity $entity, $tags = [])
+ public function saveTagsToEntity(Entity $entity, array $tags = [])
{
$entity->tags()->delete();
$newTags = [];
+
foreach ($tags as $tag) {
if (trim($tag['name']) === '') {
continue;
*/
protected $hidden = [
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
- 'created_at', 'updated_at',
+ 'created_at', 'updated_at', 'image_id',
];
/**
public $searchFactor = 2;
protected $fillable = ['name', 'description'];
- protected $hidden = ['restricted', 'pivot'];
+ protected $hidden = ['restricted', 'pivot', 'image_id'];
/**
* Get the url for this book.
protected $fillable = ['name', 'description', 'image_id'];
- protected $hidden = ['restricted'];
+ protected $hidden = ['restricted', 'image_id'];
/**
* Get the books in this shelf.
public $searchFactor = 1.3;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
+ protected $hidden = ['restricted', 'pivot'];
/**
* Get the pages that this chapter contains.
public function rebuildPermissions()
{
/** @noinspection PhpUnhandledExceptionInspection */
- Permissions::buildJointPermissionsForEntity($this);
+ Permissions::buildJointPermissionsForEntity(clone $this);
}
/**
public function indexForSearch()
{
$searchService = app()->make(SearchService::class);
- $searchService->indexEntity($this);
+ $searchService->indexEntity(clone $this);
}
/**
public $textField = 'text';
+ protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot'];
+
/**
* Get the entities that are visible to the current user.
*/
*/
protected function savePageRevision(Page $page, string $summary = null)
{
- $revision = new PageRevision($page->toArray());
+ $revision = new PageRevision($page->getAttributes());
if (setting('app-editor') !== 'markdown') {
$revision->markdown = '';
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
-class BooksApiController extends ApiController
+class BookApiController extends ApiController
{
protected $bookRepo;
'create' => [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
+ 'tags' => 'array',
],
'update' => [
'name' => 'string|min:1|max:255',
'description' => 'string|max:1000',
+ 'tags' => 'array',
],
];
use BookStack\Entities\Repos\BookRepo;
use Throwable;
-class BooksExportApiController extends ApiController
+class BookExportApiController extends ApiController
{
-
protected $bookRepo;
protected $exportService;
--- /dev/null
+<?php namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Book;
+use BookStack\Entities\Chapter;
+use BookStack\Entities\Repos\ChapterRepo;
+use BookStack\Facades\Activity;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Http\Request;
+
+class ChapterApiController extends ApiController
+{
+ protected $chapterRepo;
+
+ protected $rules = [
+ 'create' => [
+ 'book_id' => 'required|integer',
+ 'name' => 'required|string|max:255',
+ 'description' => 'string|max:1000',
+ 'tags' => 'array',
+ ],
+ 'update' => [
+ 'book_id' => 'integer',
+ 'name' => 'string|min:1|max:255',
+ 'description' => 'string|max:1000',
+ 'tags' => 'array',
+ ],
+ ];
+
+ /**
+ * ChapterController constructor.
+ */
+ public function __construct(ChapterRepo $chapterRepo)
+ {
+ $this->chapterRepo = $chapterRepo;
+ }
+
+ /**
+ * Get a listing of chapters visible to the user.
+ */
+ public function list()
+ {
+ $chapters = Chapter::visible();
+ return $this->apiListingResponse($chapters, [
+ 'id', 'book_id', 'name', 'slug', 'description', 'priority',
+ 'created_at', 'updated_at', 'created_by', 'updated_by',
+ ]);
+ }
+
+ /**
+ * Create a new chapter in the system.
+ */
+ public function create(Request $request)
+ {
+ $this->validate($request, $this->rules['create']);
+
+ $bookId = $request->get('book_id');
+ $book = Book::visible()->findOrFail($bookId);
+ $this->checkOwnablePermission('chapter-create', $book);
+
+ $chapter = $this->chapterRepo->create($request->all(), $book);
+ Activity::add($chapter, 'chapter_create', $book->id);
+
+ return response()->json($chapter->load(['tags']));
+ }
+
+ /**
+ * View the details of a single chapter.
+ */
+ public function read(string $id)
+ {
+ $chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'pages' => function (HasMany $query) {
+ $query->visible()->get(['id', 'name', 'slug']);
+ }])->findOrFail($id);
+ return response()->json($chapter);
+ }
+
+ /**
+ * Update the details of a single chapter.
+ */
+ public function update(Request $request, string $id)
+ {
+ $chapter = Chapter::visible()->findOrFail($id);
+ $this->checkOwnablePermission('chapter-update', $chapter);
+
+ $updatedChapter = $this->chapterRepo->update($chapter, $request->all());
+ Activity::add($chapter, 'chapter_update', $chapter->book->id);
+
+ return response()->json($updatedChapter->load(['tags']));
+ }
+
+ /**
+ * Delete a chapter from the system.
+ */
+ public function delete(string $id)
+ {
+ $chapter = Chapter::visible()->findOrFail($id);
+ $this->checkOwnablePermission('chapter-delete', $chapter);
+
+ $this->chapterRepo->destroy($chapter);
+ Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
+
+ return response('', 204);
+ }
+}
--- /dev/null
+<?php namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Chapter;
+use BookStack\Entities\ExportService;
+use BookStack\Entities\Repos\BookRepo;
+use Throwable;
+
+class ChapterExportApiController extends ApiController
+{
+ protected $chapterRepo;
+ protected $exportService;
+
+ /**
+ * ChapterExportController constructor.
+ */
+ public function __construct(BookRepo $chapterRepo, ExportService $exportService)
+ {
+ $this->chapterRepo = $chapterRepo;
+ $this->exportService = $exportService;
+ parent::__construct();
+ }
+
+ /**
+ * Export a chapter as a PDF file.
+ * @throws Throwable
+ */
+ public function exportPdf(int $id)
+ {
+ $chapter = Chapter::visible()->findOrFail($id);
+ $pdfContent = $this->exportService->chapterToPdf($chapter);
+ return $this->downloadResponse($pdfContent, $chapter->slug . '.pdf');
+ }
+
+ /**
+ * Export a chapter as a contained HTML file.
+ * @throws Throwable
+ */
+ public function exportHtml(int $id)
+ {
+ $chapter = Chapter::visible()->findOrFail($id);
+ $htmlContent = $this->exportService->chapterToContainedHtml($chapter);
+ return $this->downloadResponse($htmlContent, $chapter->slug . '.html');
+ }
+
+ /**
+ * Export a chapter as a plain text file.
+ */
+ public function exportPlainText(int $id)
+ {
+ $chapter = Chapter::visible()->findOrFail($id);
+ $textContent = $this->exportService->chapterToPlainText($chapter);
+ return $this->downloadResponse($textContent, $chapter->slug . '.txt');
+ }
+}
--- /dev/null
+{
+ "book_id": 1,
+ "name": "My fantastic new chapter",
+ "description": "This is a great new chapter that I've created via the API",
+ "tags": [
+ {"name": "Category", "value": "Top Content"},
+ {"name": "Rating", "value": "Highest"}
+ ]
+}
\ No newline at end of file
--- /dev/null
+{
+ "book_id": 1,
+ "name": "My fantastic updated chapter",
+ "description": "This is an updated chapter that I've altered via the API",
+ "tags": [
+ {"name": "Category", "value": "Kinda Good Content"},
+ {"name": "Rating", "value": "Medium"}
+ ]
+}
\ No newline at end of file
"updated_at": "2020-01-12 14:11:51",
"created_by": {
"id": 1,
- "name": "Admin",
- "image_id": 48
+ "name": "Admin"
},
"updated_by": {
"id": 1,
- "name": "Admin",
- "image_id": 48
+ "name": "Admin"
},
- "image_id": 452,
"tags": [
{
"id": 13,
--- /dev/null
+{
+ "book_id": 1,
+ "priority": 6,
+ "name": "My fantastic new chapter",
+ "description": "This is a great new chapter that I've created via the API",
+ "created_by": 1,
+ "updated_by": 1,
+ "slug": "my-fantastic-new-chapter",
+ "updated_at": "2020-05-22 22:59:55",
+ "created_at": "2020-05-22 22:59:55",
+ "id": 74,
+ "book": {
+ "id": 1,
+ "name": "BookStack User Guide",
+ "slug": "bookstack-user-guide",
+ "description": "This is a general guide on using BookStack on a day-to-day basis.",
+ "created_at": "2019-05-05 21:48:46",
+ "updated_at": "2019-12-11 20:57:31",
+ "created_by": 1,
+ "updated_by": 1
+ },
+ "tags": [
+ {
+ "name": "Category",
+ "value": "Top Content",
+ "order": 0,
+ "created_at": "2020-05-22 22:59:55",
+ "updated_at": "2020-05-22 22:59:55"
+ },
+ {
+ "name": "Rating",
+ "value": "Highest",
+ "order": 0,
+ "created_at": "2020-05-22 22:59:55",
+ "updated_at": "2020-05-22 22:59:55"
+ }
+ ]
+}
\ No newline at end of file
--- /dev/null
+{
+ "data": [
+ {
+ "id": 1,
+ "book_id": 1,
+ "name": "Content Creation",
+ "slug": "content-creation",
+ "description": "How to create documentation on whatever subject you need to write about.",
+ "priority": 3,
+ "created_at": "2019-05-05 21:49:56",
+ "updated_at": "2019-09-28 11:24:23",
+ "created_by": 1,
+ "updated_by": 1
+ },
+ {
+ "id": 2,
+ "book_id": 1,
+ "name": "Managing Content",
+ "slug": "managing-content",
+ "description": "How to keep things organised and orderly in the system for easier navigation and better user experience.",
+ "priority": 5,
+ "created_at": "2019-05-05 21:58:07",
+ "updated_at": "2019-10-17 15:05:34",
+ "created_by": 3,
+ "updated_by": 3
+ }
+ ],
+ "total": 40
+}
\ No newline at end of file
--- /dev/null
+{
+ "id": 1,
+ "book_id": 1,
+ "slug": "content-creation",
+ "name": "Content Creation",
+ "description": "How to create documentation on whatever subject you need to write about.",
+ "priority": 3,
+ "created_at": "2019-05-05 21:49:56",
+ "updated_at": "2019-09-28 11:24:23",
+ "created_by": {
+ "id": 1,
+ "name": "Admin"
+ },
+ "updated_by": {
+ "id": 1,
+ "name": "Admin"
+ },
+ "tags": [
+ {
+ "name": "Category",
+ "value": "Guide",
+ "order": 0,
+ "created_at": "2020-05-22 22:51:51",
+ "updated_at": "2020-05-22 22:51:51"
+ }
+ ],
+ "pages": [
+ {
+ "id": 1,
+ "book_id": 1,
+ "chapter_id": 1,
+ "name": "How to create page content",
+ "slug": "how-to-create-page-content",
+ "priority": 0,
+ "created_at": "2019-05-05 21:49:58",
+ "updated_at": "2019-08-26 14:32:59",
+ "created_by": 1,
+ "updated_by": 1,
+ "draft": 0,
+ "revision_count": 2,
+ "template": 0
+ },
+ {
+ "id": 7,
+ "book_id": 1,
+ "chapter_id": 1,
+ "name": "Good book structure",
+ "slug": "good-book-structure",
+ "priority": 1,
+ "created_at": "2019-05-05 22:01:55",
+ "updated_at": "2019-06-06 12:03:04",
+ "created_by": 3,
+ "updated_by": 3,
+ "draft": 0,
+ "revision_count": 1,
+ "template": 0
+ }
+ ]
+}
\ No newline at end of file
--- /dev/null
+{
+ "id": 75,
+ "book_id": 1,
+ "slug": "my-fantastic-updated-chapter",
+ "name": "My fantastic updated chapter",
+ "description": "This is an updated chapter that I've altered via the API",
+ "priority": 7,
+ "created_at": "2020-05-22 23:03:35",
+ "updated_at": "2020-05-22 23:07:20",
+ "created_by": 1,
+ "updated_by": 1,
+ "book": {
+ "id": 1,
+ "name": "BookStack User Guide",
+ "slug": "bookstack-user-guide",
+ "description": "This is a general guide on using BookStack on a day-to-day basis.",
+ "created_at": "2019-05-05 21:48:46",
+ "updated_at": "2019-12-11 20:57:31",
+ "created_by": 1,
+ "updated_by": 1
+ },
+ "tags": [
+ {
+ "name": "Category",
+ "value": "Kinda Good Content",
+ "order": 0,
+ "created_at": "2020-05-22 23:07:20",
+ "updated_at": "2020-05-22 23:07:20"
+ },
+ {
+ "name": "Rating",
+ "value": "Medium",
+ "order": 0,
+ "created_at": "2020-05-22 23:07:20",
+ "updated_at": "2020-05-22 23:07:20"
+ }
+ ]
+}
\ No newline at end of file
"description": "This is my shelf with some books",
"created_by": {
"id": 1,
- "name": "Admin",
- "image_id": 48
+ "name": "Admin"
},
"updated_by": {
"id": 1,
- "name": "Admin",
- "image_id": 48
+ "name": "Admin"
},
- "image_id": 501,
"created_at": "2020-04-10 13:24:09",
"updated_at": "2020-04-10 13:31:04",
"tags": [
Route::get('docs', 'ApiDocsController@display');
Route::get('docs.json', 'ApiDocsController@json');
-Route::get('books', 'BooksApiController@list');
-Route::post('books', 'BooksApiController@create');
-Route::get('books/{id}', 'BooksApiController@read');
-Route::put('books/{id}', 'BooksApiController@update');
-Route::delete('books/{id}', 'BooksApiController@delete');
+Route::get('books', 'BookApiController@list');
+Route::post('books', 'BookApiController@create');
+Route::get('books/{id}', 'BookApiController@read');
+Route::put('books/{id}', 'BookApiController@update');
+Route::delete('books/{id}', 'BookApiController@delete');
-Route::get('books/{id}/export/html', 'BooksExportApiController@exportHtml');
-Route::get('books/{id}/export/pdf', 'BooksExportApiController@exportPdf');
-Route::get('books/{id}/export/plaintext', 'BooksExportApiController@exportPlainText');
+Route::get('books/{id}/export/html', 'BookExportApiController@exportHtml');
+Route::get('books/{id}/export/pdf', 'BookExportApiController@exportPdf');
+Route::get('books/{id}/export/plaintext', 'BookExportApiController@exportPlainText');
+
+Route::get('chapters', 'ChapterApiController@list');
+Route::post('chapters', 'ChapterApiController@create');
+Route::get('chapters/{id}', 'ChapterApiController@read');
+Route::put('chapters/{id}', 'ChapterApiController@update');
+Route::delete('chapters/{id}', 'ChapterApiController@delete');
+
+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');
\ No newline at end of file
+Route::delete('shelves/{id}', 'BookshelfApiController@delete');
--- /dev/null
+<?php namespace Tests\Api;
+
+use BookStack\Entities\Book;
+use BookStack\Entities\Chapter;
+use Tests\TestCase;
+
+class ChaptersApiTest extends TestCase
+{
+ use TestsApi;
+
+ protected $baseEndpoint = '/api/chapters';
+
+ public function test_index_endpoint_returns_expected_chapter()
+ {
+ $this->actingAsApiEditor();
+ $firstChapter = Chapter::query()->orderBy('id', 'asc')->first();
+
+ $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+ $resp->assertJson(['data' => [
+ [
+ 'id' => $firstChapter->id,
+ 'name' => $firstChapter->name,
+ 'slug' => $firstChapter->slug,
+ 'book_id' => $firstChapter->book->id,
+ 'priority' => $firstChapter->priority,
+ ]
+ ]]);
+ }
+
+ public function test_create_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $book = Book::query()->first();
+ $details = [
+ 'name' => 'My API chapter',
+ 'description' => 'A chapter created via the API',
+ 'book_id' => $book->id,
+ 'tags' => [
+ [
+ 'name' => 'tagname',
+ 'value' => 'tagvalue',
+ ]
+ ]
+ ];
+
+ $resp = $this->postJson($this->baseEndpoint, $details);
+ $resp->assertStatus(200);
+ $newItem = Chapter::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->assertJsonMissing(['pages' => []]);
+ $this->assertActivityExists('chapter_create', $newItem);
+ }
+
+ public function test_chapter_name_needed_to_create()
+ {
+ $this->actingAsApiEditor();
+ $book = Book::query()->first();
+ $details = [
+ 'book_id' => $book->id,
+ 'description' => 'A chapter 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_chapter_book_id_needed_to_create()
+ {
+ $this->actingAsApiEditor();
+ $details = [
+ 'name' => 'My api chapter',
+ 'description' => 'A chapter 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."]
+ ]));
+ }
+
+ public function test_read_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $chapter = Chapter::visible()->first();
+ $page = $chapter->pages()->first();
+
+ $resp = $this->getJson($this->baseEndpoint . "/{$chapter->id}");
+ $resp->assertStatus(200);
+ $resp->assertJson([
+ 'id' => $chapter->id,
+ 'slug' => $chapter->slug,
+ 'created_by' => [
+ 'name' => $chapter->createdBy->name,
+ ],
+ 'book_id' => $chapter->book_id,
+ 'updated_by' => [
+ 'name' => $chapter->createdBy->name,
+ ],
+ 'pages' => [
+ [
+ 'id' => $page->id,
+ 'slug' => $page->slug,
+ 'name' => $page->name,
+ ]
+ ],
+ ]);
+ $resp->assertJsonCount($chapter->pages()->count(), 'pages');
+ }
+
+ public function test_update_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $chapter = Chapter::visible()->first();
+ $details = [
+ 'name' => 'My updated API chapter',
+ 'description' => 'A chapter created via the API',
+ 'tags' => [
+ [
+ 'name' => 'freshtag',
+ 'value' => 'freshtagval',
+ ]
+ ],
+ ];
+
+ $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details);
+ $chapter->refresh();
+
+ $resp->assertStatus(200);
+ $resp->assertJson(array_merge($details, [
+ 'id' => $chapter->id, 'slug' => $chapter->slug, 'book_id' => $chapter->book_id
+ ]));
+ $this->assertActivityExists('chapter_update', $chapter);
+ }
+
+ public function test_delete_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $chapter = Chapter::visible()->first();
+ $resp = $this->deleteJson($this->baseEndpoint . "/{$chapter->id}");
+
+ $resp->assertStatus(204);
+ $this->assertActivityExists('chapter_delete');
+ }
+
+ public function test_export_html_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $chapter = Chapter::visible()->first();
+
+ $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/html");
+ $resp->assertStatus(200);
+ $resp->assertSee($chapter->name);
+ $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
+ }
+
+ public function test_export_plain_text_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $chapter = Chapter::visible()->first();
+
+ $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/plaintext");
+ $resp->assertStatus(200);
+ $resp->assertSee($chapter->name);
+ $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
+ }
+
+ public function test_export_pdf_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $chapter = Chapter::visible()->first();
+
+ $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/pdf");
+ $resp->assertStatus(200);
+ $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
+ }
+}
\ No newline at end of file
return ["error" => ["code" => $code, "message" => $message]];
}
+ /**
+ * Format the given (field_name => ["messages"]) array
+ * into a standard validation response format.
+ */
+ protected function validationResponse(array $messages): array
+ {
+ $err = $this->errorResponse("The given data was invalid.", 422);
+ $err['error']['validation'] = $messages;
+ return $err;
+ }
/**
* Get an approved API auth header.
*/