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') + +