From 20e093a7a1f5bc56936c9da4456e7646c52f71ef Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Dec 2021 15:40:52 +0000 Subject: [PATCH] Added ability to copy/clone chapters Builds upon page clone work. Takes permissions into account to decide if child pages should be copied. --- app/Entities/Models/Chapter.php | 2 +- app/Entities/Repos/ChapterRepo.php | 33 +++++--- app/Entities/Repos/PageRepo.php | 2 +- app/Entities/Tools/Cloner.php | 61 +++++++++++--- app/Http/Controllers/ChapterController.php | 47 +++++++++++ resources/lang/en/entities.php | 2 + resources/views/chapters/copy.blade.php | 48 +++++++++++ resources/views/chapters/show.blade.php | 6 ++ routes/web.php | 2 + tests/Entity/ChapterTest.php | 92 ++++++++++++++++++++++ 10 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 resources/views/chapters/copy.blade.php diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index 08d6608a9..af4bbd8e3 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -18,7 +18,7 @@ class Chapter extends BookChild public $searchFactor = 1.2; - protected $fillable = ['name', 'description', 'priority', 'book_id']; + protected $fillable = ['name', 'description', 'priority']; protected $hidden = ['restricted', 'pivot', 'deleted_at']; /** diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index b10fc4530..87f9e9e40 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -5,6 +5,7 @@ namespace BookStack\Entities\Repos; use BookStack\Actions\ActivityType; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Entity; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; @@ -87,17 +88,9 @@ class ChapterRepo */ public function move(Chapter $chapter, string $parentIdentifier): Book { - $stringExploded = explode(':', $parentIdentifier); - $entityType = $stringExploded[0]; - $entityId = intval($stringExploded[1]); - - if ($entityType !== 'book') { - throw new MoveOperationException('Chapters can only be moved into books'); - } - /** @var Book $parent */ - $parent = Book::visible()->where('id', '=', $entityId)->first(); - if ($parent === null) { + $parent = $this->findParentByIdentifier($parentIdentifier); + if (is_null($parent)) { throw new MoveOperationException('Book to move chapter into not found'); } @@ -107,4 +100,24 @@ class ChapterRepo return $parent; } + + /** + * Find a page parent entity via an identifier string in the format: + * {type}:{id} + * Example: (book:5). + * + * @throws MoveOperationException + */ + public function findParentByIdentifier(string $identifier): ?Book + { + $stringExploded = explode(':', $identifier); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); + + if ($entityType !== 'book') { + throw new MoveOperationException('Chapters can only be in books'); + } + + return Book::visible()->where('id', '=', $entityId)->first(); + } } diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index b914632b5..992946461 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -347,7 +347,7 @@ class PageRepo } /** - * Find a page parent entity via a identifier string in the format: + * Find a page parent entity via an identifier string in the format: * {type}:{id} * Example: (book:5). * diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php index 3ce4dff20..d74f2f195 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -2,8 +2,12 @@ namespace BookStack\Entities\Tools; +use BookStack\Actions\Tag; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; +use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\PageRepo; class Cloner @@ -14,9 +18,15 @@ class Cloner */ protected $pageRepo; - public function __construct(PageRepo $pageRepo) + /** + * @var ChapterRepo + */ + protected $chapterRepo; + + public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo) { $this->pageRepo = $pageRepo; + $this->chapterRepo = $chapterRepo; } /** @@ -27,18 +37,49 @@ class Cloner $copyPage = $this->pageRepo->getNewDraftPage($parent); $pageData = $original->getAttributes(); - // Update name + // Update name & tags $pageData['name'] = $newName; - - // Copy tags from previous page if set - if ($original->tags) { - $pageData['tags'] = []; - foreach ($original->tags as $tag) { - $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value]; - } - } + $pageData['tags'] = $this->entityTagsToInputArray($original); return $this->pageRepo->publishDraft($copyPage, $pageData); } + /** + * Clone the given page into the given parent using the provided name. + * Clones all child pages. + */ + public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter + { + $chapterDetails = $original->getAttributes(); + $chapterDetails['name'] = $newName; + $chapterDetails['tags'] = $this->entityTagsToInputArray($original); + + $copyChapter = $this->chapterRepo->create($chapterDetails, $parent); + + if (userCan('page-create', $copyChapter)) { + /** @var Page $page */ + foreach ($original->getVisiblePages() as $page) { + $this->clonePage($page, $copyChapter, $page->name); + } + } + + return $copyChapter; + } + + /** + * Convert the tags on the given entity to the raw format + * that's used for incoming request data. + */ + protected function entityTagsToInputArray(Entity $entity): array + { + $tags = []; + + /** @var Tag $tag */ + foreach ($entity->tags as $tag) { + $tags[] = ['name' => $tag->name, 'value' => $tag->value]; + } + + return $tags; + } + } \ No newline at end of file diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 9d2bd2489..085285fc6 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -6,6 +6,7 @@ use BookStack\Actions\View; use BookStack\Entities\Models\Book; use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Tools\BookContents; +use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\NextPreviousContentLocator; use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Exceptions\MoveOperationException; @@ -190,6 +191,52 @@ class ChapterController extends Controller return redirect($chapter->getUrl()); } + /** + * Show the view to copy a chapter. + * + * @throws NotFoundException + */ + public function showCopy(string $bookSlug, string $chapterSlug) + { + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); + $this->checkOwnablePermission('chapter-view', $chapter); + + session()->flashInput(['name' => $chapter->name]); + + return view('chapters.copy', [ + 'book' => $chapter->book, + 'chapter' => $chapter, + ]); + } + + /** + * Create a copy of a page within the requested target destination. + * + * @throws NotFoundException + * @throws Throwable + */ + public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug) + { + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); + $this->checkOwnablePermission('chapter-view', $chapter); + + $entitySelection = $request->get('entity_selection') ?: null; + $newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent(); + + if (is_null($newParentBook)) { + $this->showErrorNotification(trans('errors.selected_book_not_found')); + return redirect()->back(); + } + + $this->checkOwnablePermission('chapter-create', $newParentBook); + + $newName = $request->get('name') ?: $chapter->name; + $chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName); + $this->showSuccessNotification(trans('entities.chapters_copy_success')); + + return redirect($chapterCopy->getUrl()); + } + /** * Show the Restrictions view. * diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 5cf47629a..665e833f4 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -161,6 +161,8 @@ return [ 'chapters_move' => 'Move Chapter', 'chapters_move_named' => 'Move Chapter :chapterName', 'chapter_move_success' => 'Chapter moved to :bookName', + 'chapters_copy' => 'Copy Chapter', + 'chapters_copy_success' => 'Chapter successfully copied', 'chapters_permissions' => 'Chapter Permissions', 'chapters_empty' => 'No pages are currently in this chapter.', 'chapters_permissions_active' => 'Chapter Permissions Active', diff --git a/resources/views/chapters/copy.blade.php b/resources/views/chapters/copy.blade.php new file mode 100644 index 000000000..dc4f87458 --- /dev/null +++ b/resources/views/chapters/copy.blade.php @@ -0,0 +1,48 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('entities.breadcrumbs', ['crumbs' => [ + $chapter->book, + $chapter, + $chapter->getUrl('/copy') => [ + 'text' => trans('entities.chapters_copy'), + 'icon' => 'copy', + ] + ]]) +
+ +
+ +

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

+ +
+ {!! csrf_field() !!} + +
+ + @include('form.text', ['name' => 'name']) +
+ +
+ +
+ @include('entities.selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book', 'entityPermission' => 'chapter-create']) +
+
+ +
+ {{ trans('common.cancel') }} + +
+
+ +
+
+ +@stop diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index 1646d4f18..edd39edde 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -108,6 +108,12 @@ {{ trans('common.edit') }} @endif + @if(userCanOnAny('chapter-create')) + + @icon('copy') + {{ trans('common.copy') }} + + @endif @if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter)) @icon('folder') diff --git a/routes/web.php b/routes/web.php index d7e734c33..13cf2909b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -127,6 +127,8 @@ Route::middleware('auth')->group(function () { Route::put('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'update']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'showMove']); Route::put('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'move']); + 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::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'showPermissions']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ChapterExportController::class, 'pdf']); diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php index 9868dc030..1d28ec839 100644 --- a/tests/Entity/ChapterTest.php +++ b/tests/Entity/ChapterTest.php @@ -4,6 +4,7 @@ namespace Tests\Entity; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Page; use Tests\TestCase; class ChapterTest extends TestCase @@ -54,4 +55,95 @@ class ChapterTest extends TestCase $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location')); $redirectReq->assertNotificationContains('Chapter Successfully Deleted'); } + + public function test_show_view_has_copy_button() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->first(); + + $resp = $this->asEditor()->get($chapter->getUrl()); + $resp->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_copy_view() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->first(); + + $resp = $this->asEditor()->get($chapter->getUrl('/copy')); + $resp->assertOk(); + $resp->assertSee('Copy Chapter'); + $resp->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]"); + $resp->assertElementExists("input[name=\"entity_selection\"]"); + } + + public function test_copy() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + /** @var Book $otherBook */ + $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); + + $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + 'entity_selection' => 'book:' . $otherBook->id, + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + + $resp->assertRedirect($newChapter->getUrl()); + $this->assertEquals($otherBook->id, $newChapter->book_id); + $this->assertEquals($chapter->pages->count(), $newChapter->pages->count()); + } + + public function test_copy_does_not_copy_non_visible_pages() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + + // Hide pages to all non-admin roles + /** @var Page $page */ + foreach ($chapter->pages as $page) { + $page->restricted = true; + $page->save(); + $this->regenEntityPermissions($page); + } + + $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + } + + public function test_copy_does_not_copy_pages_if_user_cant_page_create() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + $viewer = $this->getViewer(); + $this->giveUserPermissions($viewer, ['chapter-create-all']); + + // Lacking permission results in no copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + + $this->giveUserPermissions($viewer, ['page-create-all']); + + // Having permission rules in copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied again chapter', + ]); + + /** @var Chapter $newChapter2 */ + $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first(); + $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count()); + } }