public $searchFactor = 1.2;
- protected $fillable = ['name', 'description', 'priority', 'book_id'];
+ protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
/**
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;
*/
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');
}
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();
+ }
}
}
/**
- * 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).
*
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
*/
protected $pageRepo;
- public function __construct(PageRepo $pageRepo)
+ /**
+ * @var ChapterRepo
+ */
+ protected $chapterRepo;
+
+ public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo)
{
$this->pageRepo = $pageRepo;
+ $this->chapterRepo = $chapterRepo;
}
/**
$copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $original->getAttributes();
- // Update name
+ // Update name & tags
$pageData['name'] = $newName;
+ $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);
- // 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];
+ $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 $this->pageRepo->publishDraft($copyPage, $pageData);
+ 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
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;
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.
*
'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',
--- /dev/null
+@extends('layouts.simple')
+
+@section('body')
+
+ <div class="container small">
+
+ <div class="my-s">
+ @include('entities.breadcrumbs', ['crumbs' => [
+ $chapter->book,
+ $chapter,
+ $chapter->getUrl('/copy') => [
+ 'text' => trans('entities.chapters_copy'),
+ 'icon' => 'copy',
+ ]
+ ]])
+ </div>
+
+ <div class="card content-wrap auto-height">
+
+ <h1 class="list-heading">{{ trans('entities.chapters_copy') }}</h1>
+
+ <form action="{{ $chapter->getUrl('/copy') }}" method="POST">
+ {!! csrf_field() !!}
+
+ <div class="form-group title-input">
+ <label for="name">{{ trans('common.name') }}</label>
+ @include('form.text', ['name' => 'name'])
+ </div>
+
+ <div class="form-group" collapsible>
+ <button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false">
+ <label for="entity_selection">{{ trans('entities.pages_copy_desination') }}</label>
+ </button>
+ <div class="collapse-content" collapsible-content>
+ @include('entities.selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book', 'entityPermission' => 'chapter-create'])
+ </div>
+ </div>
+
+ <div class="form-group text-right">
+ <a href="{{ $chapter->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
+ <button type="submit" class="button">{{ trans('entities.chapters_copy') }}</button>
+ </div>
+ </form>
+
+ </div>
+ </div>
+
+@stop
<span>{{ trans('common.edit') }}</span>
</a>
@endif
+ @if(userCanOnAny('chapter-create'))
+ <a href="{{ $chapter->getUrl('/copy') }}" class="icon-list-item">
+ <span>@icon('copy')</span>
+ <span>{{ trans('common.copy') }}</span>
+ </a>
+ @endif
@if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter))
<a href="{{ $chapter->getUrl('/move') }}" class="icon-list-item">
<span>@icon('folder')</span>
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']);
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
use Tests\TestCase;
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());
+ }
}