mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Added ability to copy/clone chapters
Builds upon page clone work. Takes permissions into account to decide if child pages should be copied.
This commit is contained in:
parent
3f9527f166
commit
20e093a7a1
@ -18,7 +18,7 @@ class Chapter extends BookChild
|
|||||||
|
|
||||||
public $searchFactor = 1.2;
|
public $searchFactor = 1.2;
|
||||||
|
|
||||||
protected $fillable = ['name', 'description', 'priority', 'book_id'];
|
protected $fillable = ['name', 'description', 'priority'];
|
||||||
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
|
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -5,6 +5,7 @@ namespace BookStack\Entities\Repos;
|
|||||||
use BookStack\Actions\ActivityType;
|
use BookStack\Actions\ActivityType;
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Entities\Tools\TrashCan;
|
use BookStack\Entities\Tools\TrashCan;
|
||||||
use BookStack\Exceptions\MoveOperationException;
|
use BookStack\Exceptions\MoveOperationException;
|
||||||
@ -87,17 +88,9 @@ class ChapterRepo
|
|||||||
*/
|
*/
|
||||||
public function move(Chapter $chapter, string $parentIdentifier): Book
|
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 */
|
/** @var Book $parent */
|
||||||
$parent = Book::visible()->where('id', '=', $entityId)->first();
|
$parent = $this->findParentByIdentifier($parentIdentifier);
|
||||||
if ($parent === null) {
|
if (is_null($parent)) {
|
||||||
throw new MoveOperationException('Book to move chapter into not found');
|
throw new MoveOperationException('Book to move chapter into not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,4 +100,24 @@ class ChapterRepo
|
|||||||
|
|
||||||
return $parent;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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}
|
* {type}:{id}
|
||||||
* Example: (book:5).
|
* Example: (book:5).
|
||||||
*
|
*
|
||||||
|
@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
namespace BookStack\Entities\Tools;
|
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\Entity;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Entities\Repos\ChapterRepo;
|
||||||
use BookStack\Entities\Repos\PageRepo;
|
use BookStack\Entities\Repos\PageRepo;
|
||||||
|
|
||||||
class Cloner
|
class Cloner
|
||||||
@ -14,9 +18,15 @@ class Cloner
|
|||||||
*/
|
*/
|
||||||
protected $pageRepo;
|
protected $pageRepo;
|
||||||
|
|
||||||
public function __construct(PageRepo $pageRepo)
|
/**
|
||||||
|
* @var ChapterRepo
|
||||||
|
*/
|
||||||
|
protected $chapterRepo;
|
||||||
|
|
||||||
|
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo)
|
||||||
{
|
{
|
||||||
$this->pageRepo = $pageRepo;
|
$this->pageRepo = $pageRepo;
|
||||||
|
$this->chapterRepo = $chapterRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,18 +37,49 @@ class Cloner
|
|||||||
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
||||||
$pageData = $original->getAttributes();
|
$pageData = $original->getAttributes();
|
||||||
|
|
||||||
// Update name
|
// Update name & tags
|
||||||
$pageData['name'] = $newName;
|
$pageData['name'] = $newName;
|
||||||
|
$pageData['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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->pageRepo->publishDraft($copyPage, $pageData);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -6,6 +6,7 @@ use BookStack\Actions\View;
|
|||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Repos\ChapterRepo;
|
use BookStack\Entities\Repos\ChapterRepo;
|
||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
|
use BookStack\Entities\Tools\Cloner;
|
||||||
use BookStack\Entities\Tools\NextPreviousContentLocator;
|
use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||||
use BookStack\Exceptions\MoveOperationException;
|
use BookStack\Exceptions\MoveOperationException;
|
||||||
@ -190,6 +191,52 @@ class ChapterController extends Controller
|
|||||||
return redirect($chapter->getUrl());
|
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.
|
* Show the Restrictions view.
|
||||||
*
|
*
|
||||||
|
@ -161,6 +161,8 @@ return [
|
|||||||
'chapters_move' => 'Move Chapter',
|
'chapters_move' => 'Move Chapter',
|
||||||
'chapters_move_named' => 'Move Chapter :chapterName',
|
'chapters_move_named' => 'Move Chapter :chapterName',
|
||||||
'chapter_move_success' => 'Chapter moved to :bookName',
|
'chapter_move_success' => 'Chapter moved to :bookName',
|
||||||
|
'chapters_copy' => 'Copy Chapter',
|
||||||
|
'chapters_copy_success' => 'Chapter successfully copied',
|
||||||
'chapters_permissions' => 'Chapter Permissions',
|
'chapters_permissions' => 'Chapter Permissions',
|
||||||
'chapters_empty' => 'No pages are currently in this chapter.',
|
'chapters_empty' => 'No pages are currently in this chapter.',
|
||||||
'chapters_permissions_active' => 'Chapter Permissions Active',
|
'chapters_permissions_active' => 'Chapter Permissions Active',
|
||||||
|
48
resources/views/chapters/copy.blade.php
Normal file
48
resources/views/chapters/copy.blade.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
@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
|
@ -108,6 +108,12 @@
|
|||||||
<span>{{ trans('common.edit') }}</span>
|
<span>{{ trans('common.edit') }}</span>
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@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))
|
@if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter))
|
||||||
<a href="{{ $chapter->getUrl('/move') }}" class="icon-list-item">
|
<a href="{{ $chapter->getUrl('/move') }}" class="icon-list-item">
|
||||||
<span>@icon('folder')</span>
|
<span>@icon('folder')</span>
|
||||||
|
@ -127,6 +127,8 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::put('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'update']);
|
Route::put('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'update']);
|
||||||
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'showMove']);
|
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'showMove']);
|
||||||
Route::put('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'move']);
|
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}/edit', [ChapterController::class, 'edit']);
|
||||||
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'showPermissions']);
|
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/pdf', [ChapterExportController::class, 'pdf']);
|
||||||
|
@ -4,6 +4,7 @@ namespace Tests\Entity;
|
|||||||
|
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ChapterTest extends TestCase
|
class ChapterTest extends TestCase
|
||||||
@ -54,4 +55,95 @@ class ChapterTest extends TestCase
|
|||||||
$redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
|
$redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
|
||||||
$redirectReq->assertNotificationContains('Chapter Successfully Deleted');
|
$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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user