diff --git a/app/Actions/ActivityType.php b/app/Actions/ActivityType.php index 8b5213a8b..0ad25a5ab 100644 --- a/app/Actions/ActivityType.php +++ b/app/Actions/ActivityType.php @@ -16,11 +16,13 @@ class ActivityType const CHAPTER_MOVE = 'chapter_move'; const BOOK_CREATE = 'book_create'; + const BOOK_CREATE_FROM_CHAPTER = 'book_create_from_chapter'; const BOOK_UPDATE = 'book_update'; const BOOK_DELETE = 'book_delete'; const BOOK_SORT = 'book_sort'; const BOOKSHELF_CREATE = 'bookshelf_create'; + const BOOKSHELF_CREATE_FROM_BOOK = 'bookshelf_create_from_book'; const BOOKSHELF_UPDATE = 'bookshelf_update'; const BOOKSHELF_DELETE = 'bookshelf_delete'; diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 7c4b280a8..b5944fd46 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -91,6 +91,7 @@ class BookRepo { $book = new Book(); $this->baseRepo->create($book, $input); + $this->baseRepo->updateCoverImage($book, $input['image'] ?? null); Activity::add(ActivityType::BOOK_CREATE, $book); return $book; @@ -102,6 +103,11 @@ class BookRepo public function update(Book $book, array $input): Book { $this->baseRepo->update($book, $input); + + if (array_key_exists('image', $input)) { + $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null); + } + Activity::add(ActivityType::BOOK_UPDATE, $book); return $book; diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index ceabba59a..b85289b97 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -6,12 +6,10 @@ use BookStack\Actions\ActivityType; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Tools\TrashCan; -use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\NotFoundException; use BookStack\Facades\Activity; use Exception; use Illuminate\Contracts\Pagination\LengthAwarePaginator; -use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; class BookshelfRepo @@ -89,6 +87,7 @@ class BookshelfRepo { $shelf = new Bookshelf(); $this->baseRepo->create($shelf, $input); + $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null); $this->updateBooks($shelf, $bookIds); Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); @@ -106,14 +105,17 @@ class BookshelfRepo $this->updateBooks($shelf, $bookIds); } + if (array_key_exists('image', $input)) { + $this->baseRepo->updateCoverImage($shelf, $input['image'], $input['image'] === null); + } + Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf); return $shelf; } /** - * Update which books are assigned to this shelf by - * syncing the given book ids. + * Update which books are assigned to this shelf by syncing the given book ids. * Function ensures the books are visible to the current user and existing. */ protected function updateBooks(Bookshelf $shelf, array $bookIds) @@ -132,17 +134,6 @@ class BookshelfRepo $shelf->books()->sync($syncData); } - /** - * Update the given shelf cover image, or clear it. - * - * @throws ImageUploadException - * @throws Exception - */ - public function updateCoverImage(Bookshelf $shelf, ?UploadedFile $coverImage, bool $removeImage = false) - { - $this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage); - } - /** * Copy down the permissions of the given shelf to all child books. */ diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index c106d2fd3..e3c6bd17a 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -392,23 +392,6 @@ class PageRepo return $parentClass::visible()->where('id', '=', $entityId)->first(); } - /** - * Change the page's parent to the given entity. - */ - protected function changeParent(Page $page, Entity $parent) - { - $book = ($parent instanceof Chapter) ? $parent->book : $parent; - $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0; - $page->save(); - - if ($page->book->id !== $book->id) { - $page->changeBook($book->id); - } - - $page->load('book'); - $book->rebuildPermissions(); - } - /** * Get a page revision to update for the given page. * Checks for an existing revisions before providing a fresh one. diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php index b4923b90a..92b62a754 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -16,25 +16,10 @@ use Illuminate\Http\UploadedFile; class Cloner { - /** - * @var PageRepo - */ - protected $pageRepo; - - /** - * @var ChapterRepo - */ - protected $chapterRepo; - - /** - * @var BookRepo - */ - protected $bookRepo; - - /** - * @var ImageService - */ - protected $imageService; + protected PageRepo $pageRepo; + protected ChapterRepo $chapterRepo; + protected BookRepo $bookRepo; + protected ImageService $imageService; public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService) { @@ -50,11 +35,8 @@ class Cloner public function clonePage(Page $original, Entity $parent, string $newName): Page { $copyPage = $this->pageRepo->getNewDraftPage($parent); - $pageData = $original->getAttributes(); - - // Update name & tags + $pageData = $this->entityToInputData($original); $pageData['name'] = $newName; - $pageData['tags'] = $this->entityTagsToInputArray($original); return $this->pageRepo->publishDraft($copyPage, $pageData); } @@ -65,9 +47,8 @@ class Cloner */ public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter { - $chapterDetails = $original->getAttributes(); + $chapterDetails = $this->entityToInputData($original); $chapterDetails['name'] = $newName; - $chapterDetails['tags'] = $this->entityTagsToInputArray($original); $copyChapter = $this->chapterRepo->create($chapterDetails, $parent); @@ -87,9 +68,8 @@ class Cloner */ public function cloneBook(Book $original, string $newName): Book { - $bookDetails = $original->getAttributes(); + $bookDetails = $this->entityToInputData($original); $bookDetails['name'] = $newName; - $bookDetails['tags'] = $this->entityTagsToInputArray($original); $copyBook = $this->bookRepo->create($bookDetails); @@ -104,26 +84,48 @@ class Cloner } } - if ($original->cover) { - try { - $tmpImgFile = tmpfile(); - $uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile); - $this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false); - } catch (\Exception $exception) { - } + return $copyBook; + } + + /** + * Convert an entity to a raw data array of input data. + * + * @return array + */ + public function entityToInputData(Entity $entity): array + { + $inputData = $entity->getAttributes(); + $inputData['tags'] = $this->entityTagsToInputArray($entity); + + // Add a cover to the data if existing on the original entity + if ($entity->cover instanceof Image) { + $uploadedFile = $this->imageToUploadedFile($entity->cover); + $inputData['image'] = $uploadedFile; } - return $copyBook; + return $inputData; + } + + /** + * Copy the permission settings from the source entity to the target entity. + */ + public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void + { + $targetEntity->restricted = $sourceEntity->restricted; + $permissions = $sourceEntity->permissions()->get(['role_id', 'action'])->toArray(); + $targetEntity->permissions()->delete(); + $targetEntity->permissions()->createMany($permissions); + $targetEntity->rebuildPermissions(); } /** * Convert an image instance to an UploadedFile instance to mimic * a file being uploaded. */ - protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile + protected function imageToUploadedFile(Image $image): ?UploadedFile { $imgData = $this->imageService->getImageData($image); - $tmpImgFilePath = stream_get_meta_data($tmpFile)['uri']; + $tmpImgFilePath = tempnam(sys_get_temp_dir(), 'bs_cover_clone_'); file_put_contents($tmpImgFilePath, $imgData); return new UploadedFile($tmpImgFilePath, basename($image->path)); diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php new file mode 100644 index 000000000..50d9e2eae --- /dev/null +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -0,0 +1,87 @@ +bookRepo = $bookRepo; + $this->shelfRepo = $shelfRepo; + $this->cloner = $cloner; + $this->trashCan = $trashCan; + } + + /** + * Transform a chapter into a book. + * Does not check permissions, check before calling. + */ + public function transformChapterToBook(Chapter $chapter): Book + { + $inputData = $this->cloner->entityToInputData($chapter); + $book = $this->bookRepo->create($inputData); + $this->cloner->copyEntityPermissions($chapter, $book); + + /** @var Page $page */ + foreach ($chapter->pages as $page) { + $page->chapter_id = 0; + $page->changeBook($book->id); + } + + $this->trashCan->destroyEntity($chapter); + + Activity::add(ActivityType::BOOK_CREATE_FROM_CHAPTER, $book); + + return $book; + } + + /** + * Transform a book into a shelf. + * Does not check permissions, check before calling. + */ + public function transformBookToShelf(Book $book): Bookshelf + { + $inputData = $this->cloner->entityToInputData($book); + $shelf = $this->shelfRepo->create($inputData, []); + $this->cloner->copyEntityPermissions($book, $shelf); + + $shelfBookSyncData = []; + + /** @var Chapter $chapter */ + foreach ($book->chapters as $index => $chapter) { + $newBook = $this->transformChapterToBook($chapter); + $shelfBookSyncData[$newBook->id] = ['order' => $index]; + if (!$newBook->restricted) { + $this->cloner->copyEntityPermissions($shelf, $newBook); + } + } + + if ($book->directPages->count() > 0) { + $book->name .= ' ' . trans('entities.pages'); + $shelfBookSyncData[$book->id] = ['order' => count($shelfBookSyncData) + 1]; + $book->save(); + } else { + $this->trashCan->destroyEntity($book); + } + + $shelf->books()->sync($shelfBookSyncData); + + Activity::add(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $shelf); + + return $shelf; + } +} diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index 1e130c9e1..abec2e2d5 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -344,7 +344,7 @@ class TrashCan * * @throws Exception */ - protected function destroyEntity(Entity $entity): int + public function destroyEntity(Entity $entity): int { if ($entity instanceof Page) { return $this->destroyPage($entity); diff --git a/app/Http/Controllers/Api/BookApiController.php b/app/Http/Controllers/Api/BookApiController.php index 2b6e7a2e1..15565c361 100644 --- a/app/Http/Controllers/Api/BookApiController.php +++ b/app/Http/Controllers/Api/BookApiController.php @@ -11,19 +11,6 @@ class BookApiController extends ApiController { protected $bookRepo; - protected $rules = [ - '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'], - ], - ]; - public function __construct(BookRepo $bookRepo) { $this->bookRepo = $bookRepo; @@ -43,13 +30,15 @@ class BookApiController extends ApiController /** * Create a new book in the system. + * The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request. + * If the 'image' property is null then the book cover image will be removed. * * @throws ValidationException */ public function create(Request $request) { $this->checkPermission('book-create-all'); - $requestData = $this->validate($request, $this->rules['create']); + $requestData = $this->validate($request, $this->rules()['create']); $book = $this->bookRepo->create($requestData); @@ -68,6 +57,8 @@ class BookApiController extends ApiController /** * Update the details of a single book. + * The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request. + * If the 'image' property is null then the book cover image will be removed. * * @throws ValidationException */ @@ -76,7 +67,7 @@ class BookApiController extends ApiController $book = Book::visible()->findOrFail($id); $this->checkOwnablePermission('book-update', $book); - $requestData = $this->validate($request, $this->rules['update']); + $requestData = $this->validate($request, $this->rules()['update']); $book = $this->bookRepo->update($book, $requestData); return response()->json($book); @@ -97,4 +88,22 @@ class BookApiController extends ApiController return response('', 204); } + + protected function rules(): array + { + return [ + 'create' => [ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['string', 'max:1000'], + 'tags' => ['array'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + ], + 'update' => [ + 'name' => ['string', 'min:1', 'max:255'], + 'description' => ['string', 'max:1000'], + 'tags' => ['array'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + ], + ]; + } } diff --git a/app/Http/Controllers/Api/BookshelfApiController.php b/app/Http/Controllers/Api/BookshelfApiController.php index 2720d1db2..620df1638 100644 --- a/app/Http/Controllers/Api/BookshelfApiController.php +++ b/app/Http/Controllers/Api/BookshelfApiController.php @@ -13,21 +13,6 @@ class BookshelfApiController extends ApiController { protected BookshelfRepo $bookshelfRepo; - protected $rules = [ - 'create' => [ - 'name' => ['required', 'string', 'max:255'], - 'description' => ['string', 'max:1000'], - 'books' => ['array'], - 'tags' => ['array'], - ], - 'update' => [ - 'name' => ['string', 'min:1', 'max:255'], - 'description' => ['string', 'max:1000'], - 'books' => ['array'], - 'tags' => ['array'], - ], - ]; - /** * BookshelfApiController constructor. */ @@ -52,13 +37,15 @@ class BookshelfApiController extends ApiController * Create a new shelf in the system. * An array of books IDs can be provided in the request. These * will be added to the shelf in the same order as provided. + * The cover image of a shelf can be set by sending a file via an 'image' property within a 'multipart/form-data' request. + * If the 'image' property is null then the shelf cover image will be removed. * * @throws ValidationException */ public function create(Request $request) { $this->checkPermission('bookshelf-create-all'); - $requestData = $this->validate($request, $this->rules['create']); + $requestData = $this->validate($request, $this->rules()['create']); $bookIds = $request->get('books', []); $shelf = $this->bookshelfRepo->create($requestData, $bookIds); @@ -86,6 +73,8 @@ class BookshelfApiController extends ApiController * An array of books IDs can be provided in the request. These * will be added to the shelf in the same order as provided and overwrite * any existing book assignments. + * The cover image of a shelf can be set by sending a file via an 'image' property within a 'multipart/form-data' request. + * If the 'image' property is null then the shelf cover image will be removed. * * @throws ValidationException */ @@ -94,7 +83,7 @@ class BookshelfApiController extends ApiController $shelf = Bookshelf::visible()->findOrFail($id); $this->checkOwnablePermission('bookshelf-update', $shelf); - $requestData = $this->validate($request, $this->rules['update']); + $requestData = $this->validate($request, $this->rules()['update']); $bookIds = $request->get('books', null); $shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds); @@ -117,4 +106,24 @@ class BookshelfApiController extends ApiController return response('', 204); } + + protected function rules(): array + { + return [ + 'create' => [ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['string', 'max:1000'], + 'books' => ['array'], + 'tags' => ['array'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + ], + 'update' => [ + 'name' => ['string', 'min:1', 'max:255'], + 'description' => ['string', 'max:1000'], + 'books' => ['array'], + 'tags' => ['array'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + ], + ]; + } } diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index bc403c6d0..681ed96bb 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -9,6 +9,7 @@ use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\Cloner; +use BookStack\Entities\Tools\HierarchyTransformer; use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Entities\Tools\ShelfContext; use BookStack\Exceptions\ImageUploadException; @@ -100,7 +101,6 @@ class BookController extends Controller } $book = $this->bookRepo->create($request->all()); - $this->bookRepo->updateCoverImage($book, $request->file('image', null)); if ($bookshelf) { $bookshelf->appendBook($book); @@ -158,15 +158,20 @@ class BookController extends Controller { $book = $this->bookRepo->getBySlug($slug); $this->checkOwnablePermission('book-update', $book); - $this->validate($request, [ + + $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description' => ['string', 'max:1000'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), ]); - $book = $this->bookRepo->update($book, $request->all()); - $resetCover = $request->has('image_reset'); - $this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover); + if ($request->has('image_reset')) { + $validated['image'] = null; + } elseif (array_key_exists('image', $validated) && is_null($validated['image'])) { + unset($validated['image']); + } + + $book = $this->bookRepo->update($book, $validated); return redirect($book->getUrl()); } @@ -262,4 +267,20 @@ class BookController extends Controller return redirect($bookCopy->getUrl()); } + + /** + * Convert the chapter to a book. + */ + public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-update', $book); + $this->checkOwnablePermission('book-delete', $book); + $this->checkPermission('bookshelf-create-all'); + $this->checkPermission('book-create-all'); + + $shelf = $transformer->transformBookToShelf($book); + + return redirect($shelf->getUrl()); + } } diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index 9a7f78a85..a294bf731 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -83,15 +83,15 @@ class BookshelfController extends Controller public function store(Request $request) { $this->checkPermission('bookshelf-create-all'); - $this->validate($request, [ + $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description' => ['string', 'max:1000'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + 'tags' => ['array'], ]); $bookIds = explode(',', $request->get('books', '')); - $shelf = $this->bookshelfRepo->create($request->all(), $bookIds); - $this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null)); + $shelf = $this->bookshelfRepo->create($validated, $bookIds); return redirect($shelf->getUrl()); } @@ -160,16 +160,21 @@ class BookshelfController extends Controller { $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('bookshelf-update', $shelf); - $this->validate($request, [ + $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description' => ['string', 'max:1000'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + 'tags' => ['array'], ]); + if ($request->has('image_reset')) { + $validated['image'] = null; + } elseif (array_key_exists('image', $validated) && is_null($validated['image'])) { + unset($validated['image']); + } + $bookIds = explode(',', $request->get('books', '')); - $shelf = $this->bookshelfRepo->update($shelf, $request->all(), $bookIds); - $resetCover = $request->has('image_reset'); - $this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null), $resetCover); + $shelf = $this->bookshelfRepo->update($shelf, $validated, $bookIds); return redirect($shelf->getUrl()); } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 83b9bb692..60eb52380 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\Cloner; +use BookStack\Entities\Tools\HierarchyTransformer; use BookStack\Entities\Tools\NextPreviousContentLocator; use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Exceptions\MoveOperationException; @@ -272,4 +273,19 @@ class ChapterController extends Controller return redirect($chapter->getUrl()); } + + /** + * Convert the chapter to a book. + */ + public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug) + { + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); + $this->checkOwnablePermission('chapter-update', $chapter); + $this->checkOwnablePermission('chapter-delete', $chapter); + $this->checkPermission('book-create-all'); + + $book = $transformer->transformChapterToBook($chapter); + + return redirect($book->getUrl()); + } } diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php index 77c39b50c..edddf9aeb 100644 --- a/resources/lang/en/activities.php +++ b/resources/lang/en/activities.php @@ -28,6 +28,8 @@ return [ // Books 'book_create' => 'created book', 'book_create_notification' => 'Book successfully created', + 'book_create_from_chapter' => 'converted chapter to book', + 'book_create_from_chapter_notification' => 'Chapter successfully converted to a book', 'book_update' => 'updated book', 'book_update_notification' => 'Book successfully updated', 'book_delete' => 'deleted book', @@ -38,6 +40,8 @@ return [ // Bookshelves 'bookshelf_create' => 'created bookshelf', 'bookshelf_create_notification' => 'Bookshelf successfully created', + 'bookshelf_create_from_book' => 'converted book to bookshelf', + 'bookshelf_create_from_book_notification' => 'Book successfully converted to a shelf', 'bookshelf_update' => 'updated bookshelf', 'bookshelf_update_notification' => 'Bookshelf successfully updated', 'bookshelf_delete' => 'deleted bookshelf', diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index bed781b61..27d67487a 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -355,4 +355,16 @@ return [ 'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.', 'copy_consider_attachments' => 'Page attachments will not be copied.', 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', + + // Conversions + 'convert_to_shelf' => 'Convert to Shelf', + 'convert_to_shelf_contents_desc' => 'You can convert this book to a new shelf with the same contents. Chapters contained within this book will be converted to new books. If this book contains any pages, that are not in a chapter, this book will be renamed and contain such pages, and this book will become part of the new shelf.', + 'convert_to_shelf_permissions_desc' => 'Any permissions set on this book will be copied to the new shelf and to all new child books that don\'t have their own permissions enforced. Note that permissions on shelves do not auto-cascade to content within, as they do for books.', + 'convert_book' => 'Convert Book', + 'convert_book_confirm' => 'Are you sure you want to convert this book?', + 'convert_undo_warning' => 'This cannot be as easily undone.', + 'convert_to_book' => 'Convert to Book', + 'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.', + 'convert_chapter' => 'Convert Chapter', + 'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?', ]; diff --git a/resources/views/books/edit.blade.php b/resources/views/books/edit.blade.php index 403977121..180500e0a 100644 --- a/resources/views/books/edit.blade.php +++ b/resources/views/books/edit.blade.php @@ -14,12 +14,17 @@ ]]) -
+

{{ trans('entities.books_edit') }}

@include('books.parts.form', ['model' => $book, 'returnLocation' => $book->getUrl()])
+ + + @if(userCan('book-delete', $book) && userCan('book-create-all') && userCan('bookshelf-create-all')) + @include('books.parts.convert-to-shelf', ['book' => $book]) + @endif @stop \ No newline at end of file diff --git a/resources/views/books/parts/convert-to-shelf.blade.php b/resources/views/books/parts/convert-to-shelf.blade.php new file mode 100644 index 000000000..dde60aac0 --- /dev/null +++ b/resources/views/books/parts/convert-to-shelf.blade.php @@ -0,0 +1,26 @@ +
+

{{ trans('entities.convert_to_shelf') }}

+

+ {{ trans('entities.convert_to_shelf_contents_desc') }} +

+ {{ trans('entities.convert_to_shelf_permissions_desc') }} +

+
+ +
+
\ No newline at end of file diff --git a/resources/views/chapters/edit.blade.php b/resources/views/chapters/edit.blade.php index 65c48c18d..36058eff8 100644 --- a/resources/views/chapters/edit.blade.php +++ b/resources/views/chapters/edit.blade.php @@ -15,7 +15,7 @@ ]]) -
+

{{ trans('entities.chapters_edit') }}

@@ -23,6 +23,10 @@
+ @if(userCan('chapter-delete', $chapter) && userCan('book-create-all')) + @include('chapters.parts.convert-to-book') + @endif + @stop \ No newline at end of file diff --git a/resources/views/chapters/parts/convert-to-book.blade.php b/resources/views/chapters/parts/convert-to-book.blade.php new file mode 100644 index 000000000..516d1117a --- /dev/null +++ b/resources/views/chapters/parts/convert-to-book.blade.php @@ -0,0 +1,28 @@ +
+

{{ trans('entities.convert_to_book') }}

+
+

+ {{ trans('entities.convert_to_book_desc') }} +

+
+ +
+
+
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 37f59b970..5e16e5333 100644 --- a/routes/web.php +++ b/routes/web.php @@ -82,6 +82,7 @@ Route::middleware('auth')->group(function () { Route::get('/books/{slug}/delete', [BookController::class, 'showDelete']); Route::get('/books/{bookSlug}/copy', [BookController::class, 'showCopy']); Route::post('/books/{bookSlug}/copy', [BookController::class, 'copy']); + Route::post('/books/{bookSlug}/convert-to-shelf', [BookController::class, 'convertToShelf']); Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); Route::get('/books/{bookSlug}/export/html', [BookExportController::class, 'html']); @@ -132,6 +133,7 @@ Route::middleware('auth')->group(function () { Route::get('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'showCopy']); Route::post('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'copy']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [ChapterController::class, 'edit']); + Route::post('/books/{bookSlug}/chapter/{chapterSlug}/convert-to-book', [ChapterController::class, 'convertToBook']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'showPermissions']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ChapterExportController::class, 'pdf']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ChapterExportController::class, 'html']); diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 9fe8f8215..f426cff73 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -6,10 +6,12 @@ use BookStack\Entities\Models\Book; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Tests\TestCase; +use Tests\Uploads\UsesImages; class BooksApiTest extends TestCase { use TestsApi; + use UsesImages; protected string $baseEndpoint = '/api/books'; @@ -118,6 +120,42 @@ class BooksApiTest extends TestCase $this->assertGreaterThan(Carbon::now()->subDay()->unix(), $book->updated_at->unix()); } + public function test_update_cover_image_control() + { + $this->actingAsApiEditor(); + /** @var Book $book */ + $book = Book::visible()->first(); + $this->assertNull($book->cover); + $file = $this->getTestImage('image.png'); + + // Ensure cover image can be set via API + $resp = $this->call('PUT', $this->baseEndpoint . "/{$book->id}", [ + 'name' => 'My updated API book with image', + ], [], ['image' => $file]); + $book->refresh(); + + $resp->assertStatus(200); + $this->assertNotNull($book->cover); + + // Ensure further updates without image do not clear cover image + $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ + 'name' => 'My updated book again', + ]); + $book->refresh(); + + $resp->assertStatus(200); + $this->assertNotNull($book->cover); + + // Ensure update with null image property clears image + $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ + 'image' => null, + ]); + $book->refresh(); + + $resp->assertStatus(200); + $this->assertNull($book->cover); + } + public function test_delete_endpoint() { $this->actingAsApiEditor(); diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php index 034d4bc28..bc7b6f164 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -7,10 +7,12 @@ use BookStack\Entities\Models\Bookshelf; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Tests\TestCase; +use Tests\Uploads\UsesImages; class ShelvesApiTest extends TestCase { use TestsApi; + use UsesImages; protected string $baseEndpoint = '/api/shelves'; @@ -146,6 +148,42 @@ class ShelvesApiTest extends TestCase $this->assertTrue($shelf->books()->count() === 0); } + public function test_update_cover_image_control() + { + $this->actingAsApiEditor(); + /** @var Book $shelf */ + $shelf = Bookshelf::visible()->first(); + $this->assertNull($shelf->cover); + $file = $this->getTestImage('image.png'); + + // Ensure cover image can be set via API + $resp = $this->call('PUT', $this->baseEndpoint . "/{$shelf->id}", [ + 'name' => 'My updated API shelf with image', + ], [], ['image' => $file]); + $shelf->refresh(); + + $resp->assertStatus(200); + $this->assertNotNull($shelf->cover); + + // Ensure further updates without image do not clear cover image + $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ + 'name' => 'My updated shelf again', + ]); + $shelf->refresh(); + + $resp->assertStatus(200); + $this->assertNotNull($shelf->cover); + + // Ensure update with null image property clears image + $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ + 'image' => null, + ]); + $shelf->refresh(); + + $resp->assertStatus(200); + $this->assertNull($shelf->cover); + } + public function test_delete_endpoint() { $this->actingAsApiEditor(); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 7f102a17e..8b2702b46 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -290,6 +290,7 @@ class BookTest extends TestCase /** @var Book $copy */ $copy = Book::query()->where('name', '=', 'My copy book')->first(); + $this->assertNotNull($copy->cover); $this->assertNotEquals($book->cover->id, $copy->cover->id); } diff --git a/tests/Entity/ConvertTest.php b/tests/Entity/ConvertTest.php new file mode 100644 index 000000000..9791f77e4 --- /dev/null +++ b/tests/Entity/ConvertTest.php @@ -0,0 +1,145 @@ +first(); + + $resp = $this->asEditor()->get($chapter->getUrl('/edit')); + $resp->assertSee('Convert to Book'); + $resp->assertSee('Convert Chapter'); + $resp->assertElementExists('form[action$="/convert-to-book"] button'); + } + + public function test_convert_chapter_to_book() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + $chapter->tags()->save(new Tag(['name' => 'Category', 'value' => 'Penguins'])); + /** @var Page $childPage */ + $childPage = $chapter->pages()->first(); + + $resp = $this->asEditor()->post($chapter->getUrl('/convert-to-book')); + $resp->assertRedirectContains('/books/'); + + /** @var Book $newBook */ + $newBook = Book::query()->orderBy('id', 'desc')->first(); + + $this->assertDatabaseMissing('chapters', ['id' => $chapter->id]); + $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); + $this->assertCount(1, $newBook->tags); + $this->assertEquals('Category', $newBook->tags->first()->name); + $this->assertEquals('Penguins', $newBook->tags->first()->value); + $this->assertEquals($chapter->name, $newBook->name); + $this->assertEquals($chapter->description, $newBook->description); + + $this->assertActivityExists(ActivityType::BOOK_CREATE_FROM_CHAPTER, $newBook); + } + + public function test_convert_chapter_to_book_requires_permissions() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->first(); + $user = $this->getViewer(); + + $permissions = ['chapter-delete-all', 'book-create-all', 'chapter-update-all']; + $this->giveUserPermissions($user, $permissions); + + foreach ($permissions as $permission) { + $this->removePermissionFromUser($user, $permission); + $resp = $this->actingAs($user)->post($chapter->getUrl('/convert-to-book')); + $this->assertPermissionError($resp); + $this->giveUserPermissions($user, [$permission]); + } + + $resp = $this->actingAs($user)->post($chapter->getUrl('/convert-to-book')); + $this->assertNotPermissionError($resp); + $resp->assertRedirect(); + } + + public function test_book_edit_view_shows_convert_option() + { + $book = Book::query()->first(); + + $resp = $this->asEditor()->get($book->getUrl('/edit')); + $resp->assertSee('Convert to Shelf'); + $resp->assertSee('Convert Book'); + $resp->assertSee('Note that permissions on shelves do not auto-cascade to content'); + $resp->assertElementExists('form[action$="/convert-to-shelf"] button'); + } + + public function test_book_convert_to_shelf() + { + /** @var Book $book */ + $book = Book::query()->whereHas('directPages')->whereHas('chapters')->firstOrFail(); + $book->tags()->save(new Tag(['name' => 'Category', 'value' => 'Ducks'])); + /** @var Page $childPage */ + $childPage = $book->directPages()->first(); + /** @var Chapter $childChapter */ + $childChapter = $book->chapters()->whereHas('pages')->firstOrFail(); + /** @var Page $chapterChildPage */ + $chapterChildPage = $childChapter->pages()->firstOrFail(); + $bookChapterCount = $book->chapters()->count(); + $systemBookCount = Book::query()->count(); + + // Run conversion + $resp = $this->asEditor()->post($book->getUrl('/convert-to-shelf')); + + /** @var Bookshelf $newShelf */ + $newShelf = Bookshelf::query()->orderBy('id', 'desc')->first(); + + // Checks for new shelf + $resp->assertRedirectContains('/shelves/'); + $this->assertDatabaseMissing('chapters', ['id' => $childChapter->id]); + $this->assertCount(1, $newShelf->tags); + $this->assertEquals('Category', $newShelf->tags->first()->name); + $this->assertEquals('Ducks', $newShelf->tags->first()->value); + $this->assertEquals($book->name, $newShelf->name); + $this->assertEquals($book->description, $newShelf->description); + $this->assertEquals($newShelf->books()->count(), $bookChapterCount + 1); + $this->assertEquals($systemBookCount + $bookChapterCount, Book::query()->count()); + $this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf); + + // Checks for old book to contain child pages + $this->assertDatabaseHas('books', ['id' => $book->id, 'name' => $book->name . ' Pages']); + $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => 0]); + + // Checks for nested page + $chapterChildPage->refresh(); + $this->assertEquals(0, $chapterChildPage->chapter_id); + $this->assertEquals($childChapter->name, $chapterChildPage->book->name); + } + + public function test_book_convert_to_shelf_requires_permissions() + { + /** @var Book $book */ + $book = Book::query()->first(); + $user = $this->getViewer(); + + $permissions = ['book-delete-all', 'bookshelf-create-all', 'book-update-all', 'book-create-all']; + $this->giveUserPermissions($user, $permissions); + + foreach ($permissions as $permission) { + $this->removePermissionFromUser($user, $permission); + $resp = $this->actingAs($user)->post($book->getUrl('/convert-to-shelf')); + $this->assertPermissionError($resp); + $this->giveUserPermissions($user, [$permission]); + } + + $resp = $this->actingAs($user)->post($book->getUrl('/convert-to-shelf')); + $this->assertNotPermissionError($resp); + $resp->assertRedirect(); + } +} diff --git a/tests/OpenGraphTest.php b/tests/OpenGraphTest.php index 17a5aa2c5..43389ad78 100644 --- a/tests/OpenGraphTest.php +++ b/tests/OpenGraphTest.php @@ -6,8 +6,8 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; +use BookStack\Entities\Repos\BaseRepo; use BookStack\Entities\Repos\BookRepo; -use BookStack\Entities\Repos\BookshelfRepo; use Illuminate\Support\Str; use Tests\Uploads\UsesImages; @@ -69,8 +69,8 @@ class OpenGraphTest extends TestCase $this->assertArrayNotHasKey('image', $tags); // Test image set if image has cover image - $shelfRepo = app(BookshelfRepo::class); - $shelfRepo->updateCoverImage($shelf, $this->getTestImage('image.png')); + $baseRepo = app(BaseRepo::class); + $baseRepo->updateCoverImage($shelf, $this->getTestImage('image.png')); $resp = $this->asEditor()->get($shelf->getUrl()); $tags = $this->getOpenGraphTags($resp); diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index cbf49bf71..ce57d56f5 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -194,13 +194,23 @@ trait SharedTestHelpers /** * Completely remove the given permission name from the given user. */ - protected function removePermissionFromUser(User $user, string $permission) + protected function removePermissionFromUser(User $user, string $permissionName) { - $permission = RolePermission::query()->where('name', '=', $permission)->first(); + $permissionService = app()->make(PermissionService::class); + + /** @var RolePermission $permission */ + $permission = RolePermission::query()->where('name', '=', $permissionName)->firstOrFail(); + + $roles = $user->roles()->whereHas('permissions', function ($query) use ($permission) { + $query->where('id', '=', $permission->id); + })->get(); + /** @var Role $role */ - foreach ($user->roles as $role) { + foreach ($roles as $role) { $role->detachPermission($permission); + $permissionService->buildJointPermissionForRole($role); } + $user->clearPermissionCache(); }