]> BookStack Code Mirror - bookstack/commitdiff
Added ability to copy/clone chapters
authorDan Brown <redacted>
Sun, 19 Dec 2021 15:40:52 +0000 (15:40 +0000)
committerDan Brown <redacted>
Sun, 19 Dec 2021 15:40:52 +0000 (15:40 +0000)
Builds upon page clone work. Takes permissions into account to decide
if child pages should be copied.

app/Entities/Models/Chapter.php
app/Entities/Repos/ChapterRepo.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/Cloner.php
app/Http/Controllers/ChapterController.php
resources/lang/en/entities.php
resources/views/chapters/copy.blade.php [new file with mode: 0644]
resources/views/chapters/show.blade.php
routes/web.php
tests/Entity/ChapterTest.php

index 08d6608a9e4a5fc77578f50db98100cec91f659f..af4bbd8e3a66238d722272358086aa45f4aa970d 100644 (file)
@@ -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'];
 
     /**
index b10fc45309d8b6de5e813391687f67364fec8d07..87f9e9e40cc851f14e011b0b3ce918e1faf1aa00 100644 (file)
@@ -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();
+    }
 }
index b914632b5f12629fc30211fca17fe8407e05d44b..99294646145168a87aebe18c4fc0258911329b7e 100644 (file)
@@ -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).
      *
index 3ce4dff2064ee64deebff78610d4360c991e1b9d..d74f2f1956c7d9718c573f30c0a875daa6ae373e 100644 (file)
@@ -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;
+        $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
index 9d2bd2489b6c883e961c16936640767a3f28578a..085285fc6261caefac52788020d44a69ed8a7907 100644 (file)
@@ -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.
      *
index 5cf47629ac3a6c466d79c41cf84f86ae2590c6f1..665e833f4c59727e8f9a388bcc5a9cc3d9ddb573 100644 (file)
@@ -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 (file)
index 0000000..dc4f874
--- /dev/null
@@ -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
index 1646d4f18d0e1d33ab904eb86671dd855bbc98e9..edd39eddebed863b45c55b95396adfaae6a9880a 100644 (file)
                     <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>
index d7e734c33ed713a18b00310a428389e37d836cd0..13cf2909b04cc44fcc80874c2c496ecbb06b3dcc 100644 (file)
@@ -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']);
index 9868dc030581faa9553bca0c841db3a6f49ee286..1d28ec8393f87701a197f352aede7d5d23dca282 100644 (file)
@@ -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());
+    }
 }